diff --git a/Cargo.lock b/Cargo.lock index 69d0e51..bffad28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,12 +76,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - [[package]] name = "anstream" version = "0.6.15" @@ -388,12 +382,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - [[package]] name = "cc" version = "1.1.12" @@ -439,33 +427,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "clap" version = "4.5.16" @@ -600,61 +561,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.11" @@ -1497,32 +1403,12 @@ dependencies = [ "libc", ] -[[package]] -name = "is-terminal" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" -dependencies = [ - "hermit-abi 0.4.0", - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -1637,13 +1523,12 @@ version = "0.5.7" dependencies = [ "amdgpu-sysfs", "anyhow", + "cairo-rs", "chrono", - "criterion", "gtk4", - "itertools 0.13.0", + "itertools", "lact-client", "lact-daemon", - "lact-gui", "lact-schema", "libadwaita", "plotters", @@ -1651,6 +1536,7 @@ dependencies = [ "pretty_assertions", "relm4", "relm4-components", + "thread-priority", "tracing", "tracing-subscriber", ] @@ -1962,12 +1848,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -[[package]] -name = "oorandom" -version = "11.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" - [[package]] name = "ordered-stream" version = "0.2.0" @@ -2115,7 +1995,6 @@ dependencies = [ "num-traits", "pathfinder_geometry", "plotters-backend", - "plotters-svg", "ttf-parser", "wasm-bindgen", "web-sys", @@ -2137,15 +2016,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "plotters-svg" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b30686a7d9c3e010b84284bdd26a29f2138574f52f5eb6f794fc0ad924e705" -dependencies = [ - "plotters-backend", -] - [[package]] name = "polling" version = "3.7.3" @@ -2249,26 +2119,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" -[[package]] -name = "rayon" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.3" @@ -2698,6 +2548,20 @@ dependencies = [ "syn", ] +[[package]] +name = "thread-priority" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d3b04d33c9633b8662b167b847c7ab521f83d1ae20f2321b65b5b925e532e36" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "libc", + "log", + "rustversion", + "winapi", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -2741,16 +2605,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tokio" version = "1.39.2" diff --git a/Cargo.toml b/Cargo.toml index 0bfabd8..6a45570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,15 @@ codegen-units = 1 opt-level = "s" lto = true +[profile.release.package.cairo-rs] +opt-level = 3 + +[profile.release.package.plotters-cairo] +opt-level = 3 + +[profile.release.package.plotters] +opt-level = 3 + [profile.bench] strip = false -debug = true +debug = 1 diff --git a/lact-gui/Cargo.toml b/lact-gui/Cargo.toml index 2d55a91..80180b4 100644 --- a/lact-gui/Cargo.toml +++ b/lact-gui/Cargo.toml @@ -36,13 +36,10 @@ plotters = { version = "0.3.5", default-features = false, features = [ "full_palette", ] } plotters-cairo = "0.7.0" +cairo-rs = { version = "0.20", default-features = false } itertools = "0.13.0" -[dev-dependencies] -criterion = "0.5.1" -pretty_assertions = "1.4.0" -lact-gui = { path = ".", features = ["bench"] } +thread-priority = "1.1.0" -[[bench]] -name = "gui" -harness = false +[dev-dependencies] +pretty_assertions = "1.4.0" diff --git a/lact-gui/benches/gui.rs b/lact-gui/benches/gui.rs deleted file mode 100644 index eb36829..0000000 --- a/lact-gui/benches/gui.rs +++ /dev/null @@ -1,44 +0,0 @@ -use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; -use gtk::glib::{subclass::types::ObjectSubclassIsExt, Object}; -use lact_gui::app::{Plot, PlotData}; -use plotters::backend::SVGBackend; - -pub fn criterion_benchmark(c: &mut Criterion) { - gtk::init().unwrap(); - - let mut plot_data = PlotData::default(); - let mut time = chrono::NaiveDateTime::new( - chrono::NaiveDate::from_yo_opt(2024, 1).unwrap(), - chrono::NaiveTime::default(), - ); - for value in (0..100).step_by(5) { - plot_data.push_line_series_with_time("value", value as f64, time); - time += chrono::TimeDelta::seconds(2); - } - - let plot: Plot = Object::builder().build(); - *plot.data_mut() = plot_data.clone(); - - let imp = plot.imp(); - - c.bench_function("plot_pdf", |b| { - b.iter(|| { - let mut buf = String::new(); - let plotters_backend = SVGBackend::with_string(&mut buf, (1000, 1000)); - imp.plot_pdf(plotters_backend).unwrap(); - }) - }); - - c.bench_function("trim_plot_data", |b| { - b.iter_batched( - || plot_data.clone(), - |mut data| { - data.trim_data(60); - }, - BatchSize::SmallInput, - ) - }); -} - -criterion_group!(benches, criterion_benchmark); -criterion_main!(benches); diff --git a/lact-gui/src/app/graphs_window/plot/imp.rs b/lact-gui/src/app/graphs_window/plot/imp.rs index 3fd42bb..e15a6b6 100644 --- a/lact-gui/src/app/graphs_window/plot/imp.rs +++ b/lact-gui/src/app/graphs_window/plot/imp.rs @@ -1,17 +1,13 @@ -use super::cubic_spline::cubic_spline_interpolation; -use anyhow::Context; use chrono::NaiveDateTime; use glib::Properties; + use gtk::{glib, prelude::*, subclass::prelude::*}; -use itertools::Itertools; -use plotters::prelude::*; -use plotters::style::colors::full_palette::DEEPORANGE_100; -use plotters_cairo::CairoBackend; + use std::cell::Cell; use std::cell::RefCell; -use std::cmp::max; use std::collections::BTreeMap; -use tracing::error; + +use super::render_thread::{RenderRequest, RenderThread}; #[derive(Properties, Default)] #[properties(wrapper_type = super::Plot)] @@ -23,10 +19,12 @@ pub struct Plot { #[property(get, set)] secondary_value_suffix: RefCell, #[property(get, set)] - y_label_area_size: Cell, + y_label_area_relative_size: Cell, #[property(get, set)] - secondary_y_label_area_size: Cell, + secondary_y_label_area_relative_size: Cell, pub(super) data: RefCell, + pub(super) dirty: Cell, + render_thread: RenderThread, } #[glib::object_subclass] @@ -57,17 +55,38 @@ impl WidgetImpl for Plot { return; } - let bounds = gtk::graphene::Rect::new(0.0, 0.0, width as f32, height as f32); - let cr = snapshot.append_cairo(&bounds); - // Supersample the plot area - let backend = CairoBackend::new(&cr, (width * 2, height * 2)).unwrap(); - if let Err(err) = self.plot_pdf(backend) { - error!("Failed to plot PDF chart: {err:?}") + let last_texture = self.render_thread.get_last_texture(); + let size_changed = last_texture + .as_ref() + .map(|texture| (texture.width() as u32, texture.height() as u32) != (width, height)) + .unwrap_or(true); + + if self.dirty.replace(false) || size_changed { + self.render_thread.replace_render_request(RenderRequest { + data: self.data.borrow().clone(), + width, + height, + title: self.title.borrow().clone(), + value_suffix: self.value_suffix.borrow().clone(), + secondary_value_suffix: self.secondary_value_suffix.borrow().clone(), + y_label_area_relative_size: self.y_label_area_relative_size.get(), + secondary_y_label_relative_area_size: self + .secondary_y_label_area_relative_size + .get(), + supersample_factor: 4, + }); + } + + // Rendering is always behind by at least one frame, but it's not an issue + if let Some(texture) = last_texture { + let bounds = gtk::graphene::Rect::new(0.0, 0.0, width as f32, height as f32); + // Uses by default Trillinear texture filtering, which is quite good at 4x supersampling + snapshot.append_texture(&texture, &bounds); } } } -#[derive(Default)] +#[derive(Default, Clone)] #[cfg_attr(feature = "bench", derive(Clone))] pub struct PlotData { line_series: BTreeMap>, @@ -163,159 +182,3 @@ impl PlotData { .retain(|(time_point, _)| ((maximum_point - *time_point) / 1000) < last_seconds); } } - -impl Plot { - pub fn plot_pdf<'a, DB>(&self, backend: DB) -> anyhow::Result<()> - where - DB: DrawingBackend + 'a, - ::ErrorType: 'static, - { - let root = backend.into_drawing_area(); - - let data = self.data.borrow(); - - let start_date = data - .line_series_iter() - .filter_map(|(_, data)| Some(data.first()?.0)) - .min() - .unwrap_or_default(); - let end_date = data - .line_series_iter() - .map(|(_, value)| value) - .filter_map(|data| Some(data.first()?.0)) - .max() - .unwrap_or_default(); - - let mut maximum_value = data - .line_series_iter() - .flat_map(|(_, data)| data.iter().map(|(_, value)| value)) - .max_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)) - .cloned() - .unwrap_or_default(); - - if maximum_value < 100.0f64 { - maximum_value = 100.0f64; - } - - root.fill(&WHITE)?; - - let mut chart = ChartBuilder::on(&root) - .x_label_area_size(40) - .y_label_area_size(self.y_label_area_size.get()) - .right_y_label_area_size(self.secondary_y_label_area_size.get()) - .margin(20) - .caption(self.title.borrow().as_str(), ("sans-serif", 30)) - .build_cartesian_2d( - start_date..max(end_date, start_date + 60 * 1000), - 0f64..maximum_value, - )? - .set_secondary_coord( - start_date..max(end_date, start_date + 60 * 1000), - 0.0..100.0, - ); - - chart - .configure_mesh() - .x_label_formatter(&|date_time| { - let date_time = chrono::DateTime::from_timestamp_millis(*date_time).unwrap(); - date_time.format("%H:%M:%S").to_string() - }) - .y_label_formatter(&|x| format!("{x}{}", self.value_suffix.borrow())) - .x_labels(5) - .y_labels(10) - .label_style(("sans-serif", 30)) - .draw() - .context("Failed to draw mesh")?; - - chart - .configure_secondary_axes() - .y_label_formatter(&|x| format!("{x}{}", self.secondary_value_suffix.borrow())) - .y_labels(10) - .label_style(("sans-serif", 30)) - .draw() - .context("Failed to draw mesh")?; - - // Draw the throttling histogram - chart - .draw_series( - data.throttling_iter() - // Group segments of consecutive enabled/disabled throttlings - .chunk_by(|(_, _, point)| *point) - .into_iter() - // Filter only when throttling is enabled - .filter_map(|(point, group_iter)| point.then_some(group_iter)) - // Get last and first times - .filter_map(|mut group_iter| { - let first = group_iter.next()?; - Some((first, group_iter.last().unwrap_or(first))) - }) - // Filter out redundant data - .map(|((start, name, _), (end, _, _))| ((start, end), name)) - .map(|((start_time, end_time), _)| { - let mut bar = Rectangle::new( - [(start_time, 0f64), (end_time, maximum_value)], - DEEPORANGE_100.filled(), - ); - bar.set_margin(0, 0, 5, 5); - bar - }), - ) - .context("Failed to draw throttling histogram")?; - - for (idx, (caption, data)) in (0..).zip(data.line_series_iter()) { - chart - .draw_series(LineSeries::new( - cubic_spline_interpolation(data.iter()) - .into_iter() - .flat_map(|((first_time, second_time), segment)| { - // Interpolate in intervals of one millisecond - (first_time..second_time).map(move |current_date| { - (current_date, segment.evaluate(current_date)) - }) - }), - Palette99::pick(idx).stroke_width(1), - )) - .context("Failed to draw series")? - .label(caption) - .legend(move |(x, y)| { - Rectangle::new([(x - 10, y - 10), (x + 10, y + 10)], Palette99::pick(idx)) - }); - } - - for (idx, (caption, data)) in (0..).zip(data.secondary_line_series_iter()) { - chart - .draw_secondary_series(LineSeries::new( - cubic_spline_interpolation(data.iter()) - .into_iter() - .flat_map(|((first_time, second_time), segment)| { - // Interpolate in intervals of one millisecond - (first_time..second_time).map(move |current_date| { - (current_date, segment.evaluate(current_date)) - }) - }), - Palette99::pick(idx + 10).stroke_width(1), // Offset the pallete pick compared to the main graph - )) - .context("Failed to draw series")? - .label(caption) - .legend(move |(x, y)| { - Rectangle::new( - [(x - 10, y - 10), (x + 10, y + 10)], - Palette99::pick(idx + 10), - ) - }); - } - - chart - .configure_series_labels() - .margin(40) - .label_font(("sans-serif", 30)) - .position(SeriesLabelPosition::LowerRight) - .background_style(WHITE.mix(0.8)) - .border_style(BLACK) - .draw() - .context("Failed to draw series labels")?; - - root.present()?; - Ok(()) - } -} diff --git a/lact-gui/src/app/graphs_window/plot/mod.rs b/lact-gui/src/app/graphs_window/plot/mod.rs index 08e3c22..35843af 100644 --- a/lact-gui/src/app/graphs_window/plot/mod.rs +++ b/lact-gui/src/app/graphs_window/plot/mod.rs @@ -1,5 +1,7 @@ mod cubic_spline; mod imp; +mod render_thread; +mod to_texture_ext; use std::cell::RefMut; @@ -14,6 +16,7 @@ glib::wrapper! { impl Plot { pub fn data_mut(&self) -> RefMut<'_, PlotData> { + self.imp().dirty.set(true); self.imp().data.borrow_mut() } } diff --git a/lact-gui/src/app/graphs_window/plot/render_thread.rs b/lact-gui/src/app/graphs_window/plot/render_thread.rs new file mode 100644 index 0000000..ff757f8 --- /dev/null +++ b/lact-gui/src/app/graphs_window/plot/render_thread.rs @@ -0,0 +1,366 @@ +use super::cubic_spline::cubic_spline_interpolation; +use super::to_texture_ext::ToTextureExt; +use super::PlotData; +use anyhow::Context; +use cairo::{Context as CairoContext, ImageSurface}; + +use gtk::gdk::MemoryTexture; +use itertools::Itertools; +use plotters::prelude::*; +use plotters::style::colors::full_palette::DEEPORANGE_100; +use plotters::style::RelativeSize; +use plotters_cairo::CairoBackend; +use std::cmp::{max, min}; +use std::ops::{Deref, DerefMut}; +use std::sync::{Arc, Mutex}; +use thread_priority::{ThreadBuilderExt, ThreadPriority}; +use tracing::error; + +enum Request { + Terminate, + Render(RenderRequest), +} + +#[derive(Default)] +pub struct RenderRequest { + pub title: String, + pub value_suffix: String, + pub secondary_value_suffix: String, + pub y_label_area_relative_size: f64, + pub secondary_y_label_relative_area_size: f64, + + pub data: PlotData, + + pub width: u32, + pub height: u32, + + pub supersample_factor: u32, +} + +#[derive(Default)] +struct RenderThreadState { + request_condition_variable: std::sync::Condvar, + last_texture: Mutex>, + current_request: Mutex>, +} + +/// A rendering thread that will listen for rendering requests and process them asynchronously. +/// Requests that weren't processed in time or resulted in error are dropped. +pub struct RenderThread { + /// Shared state is between the main thread and the rendering thread. + state: Arc, + thread_handle: Option>, +} + +/// Ensure the rendering thread is terminated properly when the RenderThread object is dropped. +/// We send Request::Terminate to swiftly terminate rendering thread and then join to let it finish last render. +impl Drop for RenderThread { + fn drop(&mut self) { + self.state + .current_request + .lock() + .unwrap() + .replace(Request::Terminate); + self.state.request_condition_variable.notify_all(); + + self.thread_handle.take().map(|handle| handle.join().ok()); + } +} + +impl RenderThread { + pub fn new() -> Self { + let state = Arc::new(RenderThreadState::default()); + + let thread_handle = std::thread::Builder::new() + .name("Plot-Renderer".to_owned()) + // Render thread is very unimportant, skipping frames and rendering slowly is ok + .spawn_with_priority(ThreadPriority::Min, { + let state = state.clone(); + move |_| loop { + let RenderThreadState { + request_condition_variable, + last_texture, + current_request, + } = &*state; + + // Wait until there is a new request (blocking if there is none). + let mut current_request = request_condition_variable + .wait_while(current_request.lock().unwrap(), |pending_request| { + pending_request.is_none() + }) + .unwrap(); + + match current_request.take() { + Some(Request::Render(render_request)) => { + // Create a new ImageSurface for Cairo rendering. + let mut surface = ImageSurface::create( + cairo::Format::ARgb32, + (render_request.width * render_request.supersample_factor) as i32, + (render_request.height * render_request.supersample_factor) as i32, + ) + .unwrap(); + + let cairo_context = CairoContext::new(&surface).unwrap(); + + // Don't use Cairo's default antialiasing, it makes the lines look too blurry + // Supersampling is our 2D anti-aliasing solution. + if render_request.supersample_factor > 1 { + cairo_context.set_antialias(cairo::Antialias::None); + } + + let cairo_backend = CairoBackend::new( + &cairo_context, + // Supersample the rendering + ( + render_request.width * render_request.supersample_factor, + render_request.height * render_request.supersample_factor, + ), + ) + .unwrap(); + + if let Err(err) = render_request.draw(cairo_backend) { + error!("Failed to plot chart: {err:?}") + } + + match ( + surface.to_texture(), + last_texture.lock().unwrap().deref_mut(), + ) { + // Successfully generated a new texture, but the old texture is also there + (Some(texture), Some(last_texture)) => { + *last_texture = texture; + } + // If texture conversion failed, keep the old texture if it's present. + (None, None) => { + error!("Failed to convert cairo surface to gdk texture, not overwriting old one"); + } + // Update the last texture, if The old texture wasn't ever generated (None), + // No matter the result of conversion + (result, last_texture) => { + *last_texture = result; + } + }; + } + // Terminate the thread if a Terminate request is received. + Some(Request::Terminate) => break, + None => {} + } + } + }) + .unwrap(); + + Self { + state, + thread_handle: Some(thread_handle), + } + } + + /// Replace the current render request with a new one (effectively dropping possible pending frame) + /// Returns dropped request if any + pub fn replace_render_request(&self, request: RenderRequest) -> Option { + let mut current_request = self.state.current_request.lock().unwrap(); + let result = current_request.replace(Request::Render(request)); + self.state.request_condition_variable.notify_one(); // Notify the thread to start rendering. + + match result? { + Request::Render(render) => Some(render), + Request::Terminate => None, + } + } + + /// Return the last texture. + /// Requests that weren't processed in time or resulted in error are dropped. + pub fn get_last_texture(&self) -> Option { + self.state.last_texture.lock().unwrap().deref().clone() + } +} + +// Implement the default constructor for RenderThread using the `new` method. +impl Default for RenderThread { + fn default() -> Self { + Self::new() + } +} + +impl RenderRequest { + pub fn relative_size(&self, ratio: f64) -> f64 { + min(self.height, self.width) as f64 * ratio + } + + // Method to handle the actual drawing of the chart. + pub fn draw<'a, DB>(&self, backend: DB) -> anyhow::Result<()> + where + DB: DrawingBackend + 'a, + ::ErrorType: 'static, + { + let root = backend.into_drawing_area(); // Create the drawing area. + + let data = &self.data; + + // Determine the start and end dates of the data series. + let start_date = data + .line_series_iter() + .filter_map(|(_, data)| Some(data.first()?.0)) + .min() + .unwrap_or_default(); + let end_date = data + .line_series_iter() + .map(|(_, value)| value) + .filter_map(|data| Some(data.first()?.0)) + .max() + .unwrap_or_default(); + + // Calculate the maximum value for the y-axis. + let mut maximum_value = data + .line_series_iter() + .flat_map(|(_, data)| data.iter().map(|(_, value)| value)) + .max_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)) + .cloned() + .unwrap_or_default(); + + // Ensure that the maximum value is at least 100 for better visualization. + if maximum_value < 100.0f64 { + maximum_value = 100.0f64; + } + + root.fill(&WHITE)?; // Fill the background with white color. + + // Set up the main chart with axes and labels. + let mut chart = ChartBuilder::on(&root) + .x_label_area_size(RelativeSize::Smaller(0.05)) + .y_label_area_size(RelativeSize::Smaller(self.y_label_area_relative_size)) + .right_y_label_area_size(RelativeSize::Smaller( + self.secondary_y_label_relative_area_size, + )) + .margin(RelativeSize::Smaller(0.045)) + .caption( + self.title.as_str(), + ("sans-serif", RelativeSize::Smaller(0.08)), + ) + .build_cartesian_2d( + start_date..max(end_date, start_date + 60 * 1000), + 0f64..maximum_value, + )? + .set_secondary_coord( + start_date..max(end_date, start_date + 60 * 1000), + 0.0..100.0, + ); + + // Configure the x-axis and y-axis mesh. + chart + .configure_mesh() + .x_label_formatter(&|date_time| { + let date_time = chrono::DateTime::from_timestamp_millis(*date_time).unwrap(); + date_time.format("%H:%M:%S").to_string() + }) + .y_label_formatter(&|x| format!("{x}{}", &self.value_suffix)) + .x_labels(5) + .y_labels(10) + .label_style(("sans-serif", RelativeSize::Smaller(0.08))) + .draw() + .context("Failed to draw mesh")?; + + // Configure the secondary axes (for the secondary y-axis). + chart + .configure_secondary_axes() + .y_label_formatter(&|x| format!("{x}{}", self.secondary_value_suffix.as_str())) + .y_labels(10) + .label_style(("sans-serif", RelativeSize::Smaller(0.08))) + .draw() + .context("Failed to draw mesh")?; + + // Draw the throttling histogram as a series of bars. + chart + .draw_series( + data.throttling_iter() + .chunk_by(|(_, _, point)| *point) + .into_iter() + .filter_map(|(point, group_iter)| point.then_some(group_iter)) + .filter_map(|mut group_iter| { + let first = group_iter.next()?; + Some((first, group_iter.last().unwrap_or(first))) + }) + .map(|((start, name, _), (end, _, _))| ((start, end), name)) + .map(|((start_time, end_time), _)| (start_time, end_time)) + .sorted_by_key(|&(start_time, _)| start_time) + .coalesce(|(start1, end1), (start2, end2)| { + if end1 >= start2 { + Ok((start1, std::cmp::max(end1, end2))) + } else { + Err(((start1, end1), (start2, end2))) + } + }) + .map(|(start_time, end_time)| { + Rectangle::new( + [(start_time, 0f64), (end_time, maximum_value)], + DEEPORANGE_100.filled(), + ) + }), + ) + .context("Failed to draw throttling histogram")?; + + // Draw the main line series using cubic spline interpolation. + for (idx, (caption, data)) in (0..).zip(data.line_series_iter()) { + chart + .draw_series(LineSeries::new( + cubic_spline_interpolation(data.iter()) + .into_iter() + .flat_map(|((first_time, second_time), segment)| { + // Interpolate in intervals of one millisecond. + (first_time..second_time).map(move |current_date| { + (current_date, segment.evaluate(current_date)) + }) + }), + Palette99::pick(idx).stroke_width(8), + )) + .context("Failed to draw series")? + .label(caption) + .legend(move |(x, y)| { + let offset = self.relative_size(0.04) as i32; + Rectangle::new( + [(x - offset, y - offset), (x + offset, y + offset)], + Palette99::pick(idx).filled(), + ) + }); + } + + // Draw the secondary line series on the secondary y-axis. + for (idx, (caption, data)) in (0..).zip(data.secondary_line_series_iter()) { + chart + .draw_secondary_series(LineSeries::new( + cubic_spline_interpolation(data.iter()) + .into_iter() + .flat_map(|((first_time, second_time), segment)| { + (first_time..second_time).map(move |current_date| { + (current_date, segment.evaluate(current_date)) + }) + }), + Palette99::pick(idx + 10).stroke_width(8), + )) + .context("Failed to draw series")? + .label(caption) + .legend(move |(x, y)| { + let offset = self.relative_size(0.04) as i32; + Rectangle::new( + [(x - offset, y - offset), (x + offset, y + offset)], + Palette99::pick(idx + 10).filled(), + ) + }); + } + + // Configure and draw series labels (the legend). + chart + .configure_series_labels() + .margin(RelativeSize::Smaller(0.10)) + .label_font(("sans-serif", RelativeSize::Smaller(0.08))) + .position(SeriesLabelPosition::LowerRight) + .legend_area_size(RelativeSize::Smaller(0.045)) + .background_style(WHITE.mix(0.8)) + .border_style(BLACK) + .draw() + .context("Failed to draw series labels")?; + + root.present()?; // Present the final image. + Ok(()) + } +} diff --git a/lact-gui/src/app/graphs_window/plot/to_texture_ext.rs b/lact-gui/src/app/graphs_window/plot/to_texture_ext.rs new file mode 100644 index 0000000..6377d78 --- /dev/null +++ b/lact-gui/src/app/graphs_window/plot/to_texture_ext.rs @@ -0,0 +1,81 @@ +use std::ffi::c_void; + +use cairo::ImageSurface; +use gtk::gdk; +use gtk::gdk::MemoryTexture; +use gtk::glib; +use gtk::glib::ffi::g_bytes_new_with_free_func; +use gtk::glib::translate::FromGlibPtrFull; + +pub(super) trait ToTextureExt { + fn to_texture(&mut self) -> Option; +} + +impl ToTextureExt for ImageSurface { + fn to_texture(&mut self) -> Option { + // Ensure the surface is of type image + if self.type_() != cairo::SurfaceType::Image { + return None; + } + + let width = self.width(); + let height = self.height(); + + // Check if the surface has valid dimensions + if width <= 0 || height <= 0 { + return None; + } + + let stride = self.stride(); + let format = self.format(); + + // Use with_data to get mutable access to surface data + let mut bytes = None; + self.with_data(|data| { + // Reference the surface to be passed to the free function + let surface_ref = self.clone(); + + // Use g_bytes_new_with_free_func to manage memory + unsafe { + let ptr = g_bytes_new_with_free_func( + data.as_ptr() as *const c_void, + (height * stride) as usize, + Some(c_surface_destroy_notify), + Box::into_raw(Box::new(surface_ref)) as *mut c_void, + ); + + bytes = Some(glib::Bytes::from_glib_full(ptr)); + }; + }) + .expect("Failed to get surface data"); + + // Create the GdkTexture + let texture = MemoryTexture::new( + width, + height, + cairo_format_to_memory_format(format), + &bytes.unwrap(), + stride as usize, + ); + + Some(texture) + } +} + +// Function that will act as GDestroyNotify to free the cairo surface +extern "C" fn c_surface_destroy_notify(surface_ptr: *mut c_void) { + if !surface_ptr.is_null() { + // SAFETY: We know this is a valid ImageSurface as we passed it in Box::into_raw + let surface: Box = unsafe { Box::from_raw(surface_ptr as *mut ImageSurface) }; + drop(surface); // Automatically handles the cleanup + } +} + +// Convert cairo format to gdk::MemoryFormat +fn cairo_format_to_memory_format(format: cairo::Format) -> gdk::MemoryFormat { + match format { + cairo::Format::Rgb24 => gdk::MemoryFormat::R8g8b8, + cairo::Format::ARgb32 => gdk::MemoryFormat::R8g8b8a8, + _ => panic!("Unsupported cairo format"), + } +} diff --git a/lact-gui/ui/graphs_window.blp b/lact-gui/ui/graphs_window.blp index 262eb02..2bc0528 100644 --- a/lact-gui/ui/graphs_window.blp +++ b/lact-gui/ui/graphs_window.blp @@ -19,7 +19,7 @@ template $GraphsWindow: Window { title: "Temperature"; hexpand: true; value-suffix: "°C"; - y-label-area-size: 80; + y-label-area-relative-size: 0.15; layout { column: 0; @@ -32,8 +32,8 @@ template $GraphsWindow: Window { hexpand: true; value-suffix: "RPM"; secondary-value-suffix: "%"; - y-label-area-size: 140; - secondary-y-label-area-size: 80; + y-label-area-relative-size: 0.25; + secondary-y-label-area-relative-size: 0.15; layout { column: 0; @@ -45,7 +45,7 @@ template $GraphsWindow: Window { title: "Clockspeed"; hexpand: true; value-suffix: "MHz"; - y-label-area-size: 140; + y-label-area-relative-size: 0.25; layout { column: 1; @@ -57,7 +57,7 @@ template $GraphsWindow: Window { title: "Power usage"; hexpand: true; value-suffix: "W"; - y-label-area-size: 80; + y-label-area-relative-size: 0.2; layout { column: 1;