From 2335ca5fe579b28c7da90ae804ffef5a547f1c09 Mon Sep 17 00:00:00 2001 From: buzz Date: Sat, 20 Dec 2025 10:55:38 +0100 Subject: [PATCH] feat: add support for graph widget --- CHANGELOG.md | 1 + crates/ewwii/src/widgets/build_widget.rs | 2 +- crates/ewwii/src/widgets/graph.rs | 921 ++++++++++++------ .../ewwii/src/widgets/widget_definitions.rs | 163 ++-- .../src/widgets/widget_definitions_helper.rs | 23 + 5 files changed, 734 insertions(+), 376 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1baf80..6b2daf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `transition_duration` property to stack widget. - `widget_control` utility function for dynamic widget handling. - `text` and `show_text` property to progressbar widget. +- `graph` widget back. ### Changed diff --git a/crates/ewwii/src/widgets/build_widget.rs b/crates/ewwii/src/widgets/build_widget.rs index 37f541b..8bdd445 100644 --- a/crates/ewwii/src/widgets/build_widget.rs +++ b/crates/ewwii/src/widgets/build_widget.rs @@ -60,7 +60,7 @@ fn build_gtk_widget_from_node( build_circular_progress_bar(props, widget_reg)?.upcast() } WidgetNode::GtkUI { props } => build_gtk_ui_file(props)?.upcast(), - // WidgetNode::Graph { props } => build_graph(props, widget_reg)?.upcast(), + WidgetNode::Graph { props } => build_graph(props, widget_reg)?.upcast(), // WidgetNode::Transform { props } => build_transform(props, widget_reg)?.upcast(), WidgetNode::Scale { props } => build_gtk_scale(props, widget_reg)?.upcast(), WidgetNode::Progress { props } => build_gtk_progress(props, widget_reg)?.upcast(), diff --git a/crates/ewwii/src/widgets/graph.rs b/crates/ewwii/src/widgets/graph.rs index 21eeba0..9621551 100644 --- a/crates/ewwii/src/widgets/graph.rs +++ b/crates/ewwii/src/widgets/graph.rs @@ -1,349 +1,654 @@ -// use std::{cell::RefCell, collections::VecDeque}; -// // https://www.figuiere.net/technotes/notes/tn002/ -// // https://github.com/gtk-rs/examples/blob/master/src/bin/listbox_model.rs -// use anyhow::{anyhow, Result}; -// use gtk4::glib::{self, object_subclass, wrapper, Properties}; -// use gtk4::{cairo, gdk, prelude::*, subclass::prelude::*}; +//! Graph widget with time-series visualization +//! +//! The graph renders data points in a 2D canvas with time on the horizontal axis +//! and values on the vertical axis by default (non-vertical mode). +//! +//! Canvas Layout (default horizontal orientation, no flipping): +//! +//! value ↑ +//! │ +//! max ┼──────────────┐ (past, max) +//! │ │ +//! │ │ +//! │ Graph │ +//! │ Area │ +//! │ │ +//! min ┼──────────────┘ (past, min) +//! └──────────────┴───→ time +//! (past) (now) +//! +//! Key coordinates in widget space (after margins): +//! - (0, 0) : Top-left corner (past time, max value) +//! - (width, 0) : Top-right corner (current time, max value) +//! - (0, height): Bottom-left corner (past time, min value) +//! - (width, height): Bottom-right corner (current time, min value) +//! +//! Time flows from left (past) to right (present). +//! Most recent data points appear at the right edge. +//! Older points scroll leftward as time progresses. +//! +//! The `time-range` property controls how many milliseconds of history are visible. +//! Points older than `now - time-range` are automatically pruned. +//! +//! The graph supports multiple render types (Line, Fill, Step variants) +//! and can be flipped/rotated via properties. -// use crate::error_handling_ctx; +use gtk4::glib::property::PropertySet; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gdk, glib, graphene, gsk}; +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; -// // This widget shouldn't be a Bin/Container but I've not been -// // able to subclass just a gtk4::Widget -// wrapper! { -// pub struct Graph(ObjectSubclass) -// @extends gtk4::Bin, gtk4::Container, gtk4::Widget; -// } +mod imp { + use super::*; -// #[derive(Properties)] -// #[properties(wrapper_type = Graph)] -// pub struct GraphPriv { -// #[property(get, set, nick = "Value", blurb = "The value", minimum = 0f64, maximum = f64::MAX, default = 0f64)] -// value: RefCell, + const DEFAULT_VALUE: f64 = 0.0; + const DEFAULT_THICKNESS: f64 = 1.0; + const DEFAULT_MIN: f64 = 0.0; + const DEFAULT_MAX: f64 = 1.0; + const DEFAULT_DYNAMIC: bool = true; + const DEFAULT_TIME_RANGE: u32 = 10_000; // ms + const DEFAULT_FLIP_X: bool = false; + const DEFAULT_FLIP_Y: bool = false; + const DEFAULT_VERTICAL: bool = false; + const DEFAULT_ANIMATE: bool = true; -// #[property(get, set, nick = "Thickness", blurb = "The Thickness", minimum = 0f64, maximum = f64::MAX, default = 1f64)] -// thickness: RefCell, + pub struct Graph { + pub value: Cell, + pub thickness: Cell, + pub line_style: Cell, + pub min: Cell, + pub max: Cell, + pub dynamic: Cell, + pub time_range: Cell, + pub flip_x: Cell, + pub flip_y: Cell, + pub vertical: Cell, + pub render_type: Cell, -// #[property(get, set, nick = "Line Style", blurb = "The Line Style", default = "miter")] -// line_style: RefCell, + // Runtime state + history: RefCell>, + last_updated_at: RefCell, + tick_id: RefCell>, + min_value_cached: Cell>, + max_value_cached: Cell>, + has_received_value: Cell, -// #[property(get, set, nick = "Maximum Value", blurb = "The Maximum Value", minimum = 0f64, maximum = f64::MAX, default = 100f64)] -// min: RefCell, + // Cached path (Geometry) + cached_path: RefCell>, + // The "anchor" time used to build the path (fixed time reference) + path_anchor_time: Cell, + // Size when the path was built (for invalidation) + cached_path_size: Cell<(f32, f32)>, + } -// #[property(get, set, nick = "Minumum Value", blurb = "The Minimum Value", minimum = 0f64, maximum = f64::MAX, default = 0f64)] -// max: RefCell, + impl Default for Graph { + fn default() -> Self { + Self { + value: Cell::new(DEFAULT_VALUE), + thickness: Cell::new(DEFAULT_THICKNESS), + line_style: Cell::new(LineStyle::default()), + min: Cell::new(DEFAULT_MIN), + max: Cell::new(DEFAULT_MAX), + dynamic: Cell::new(DEFAULT_DYNAMIC), + time_range: Cell::new(DEFAULT_TIME_RANGE), + flip_x: Cell::new(DEFAULT_FLIP_X), + flip_y: Cell::new(DEFAULT_FLIP_Y), + vertical: Cell::new(DEFAULT_VERTICAL), + render_type: Cell::new(RenderType::default()), -// #[property(get, set, nick = "Dynamic", blurb = "If it is dynamic", default = true)] -// dynamic: RefCell, + history: RefCell::new(VecDeque::new()), + last_updated_at: RefCell::new(Instant::now()), + tick_id: RefCell::new(None), + min_value_cached: Cell::new(None), + max_value_cached: Cell::new(None), + has_received_value: Cell::new(false), -// #[property(get, set, nick = "Time Range", blurb = "The Time Range", minimum = 0u64, maximum = u64::MAX, default = 10u64)] -// time_range: RefCell, + cached_path: RefCell::new(None), + path_anchor_time: Cell::new(Instant::now()), + cached_path_size: Cell::new((0.0, 0.0)), + } + } + } -// #[property(get, set, nick = "Flip X", blurb = "Flip the x axis", default = true)] -// flip_x: RefCell, -// #[property(get, set, nick = "Flip Y", blurb = "Flip the y axis", default = true)] -// flip_y: RefCell, -// #[property(get, set, nick = "Vertical", blurb = "Exchange the x and y axes", default = false)] -// vertical: RefCell, + #[glib::object_subclass] + impl ObjectSubclass for Graph { + const NAME: &'static str = "EwwiiGraph"; + type Type = super::Graph; + type ParentType = gtk4::Widget; + } -// history: RefCell>, -// extra_point: RefCell>, -// last_updated_at: RefCell, -// } + impl ObjectImpl for Graph { + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + obj.add_css_class("graph"); -// impl Default for GraphPriv { -// fn default() -> Self { -// Self { -// value: RefCell::new(0.0), -// thickness: RefCell::new(1.0), -// line_style: RefCell::new("miter".to_string()), -// min: RefCell::new(0.0), -// max: RefCell::new(100.0), -// dynamic: RefCell::new(true), -// time_range: RefCell::new(10), -// flip_x: RefCell::new(true), -// flip_y: RefCell::new(true), -// vertical: RefCell::new(false), -// history: RefCell::new(VecDeque::new()), -// extra_point: RefCell::new(None), -// last_updated_at: RefCell::new(std::time::Instant::now()), -// } -// } -// } + if DEFAULT_ANIMATE { + self.set_animate(&obj, true); + } + } -// impl GraphPriv { -// // Updates the history, removing points ouside the range -// fn update_history(&self, v: (std::time::Instant, f64)) { -// let mut history = self.history.borrow_mut(); -// let mut last_value = self.extra_point.borrow_mut(); -// let mut last_updated_at = self.last_updated_at.borrow_mut(); -// *last_updated_at = std::time::Instant::now(); + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecDouble::builder("value") + .minimum(0.0) + .maximum(f64::MAX) + .default_value(DEFAULT_VALUE) + .build(), + glib::ParamSpecDouble::builder("thickness") + .minimum(0.0) + .maximum(f64::MAX) + .default_value(DEFAULT_THICKNESS) + .build(), + glib::ParamSpecEnum::builder::("line-style") + .default_value(LineStyle::default()) + .build(), + glib::ParamSpecDouble::builder("min") + .minimum(f64::MIN) + .maximum(f64::MAX) + .default_value(DEFAULT_MIN) + .build(), + glib::ParamSpecDouble::builder("max") + .minimum(f64::MIN) + .maximum(f64::MAX) + .default_value(DEFAULT_MAX) + .build(), + glib::ParamSpecBoolean::builder("dynamic") + .default_value(DEFAULT_DYNAMIC) + .build(), + glib::ParamSpecUInt::builder("time-range") + .minimum(0) + .maximum(u32::MAX) + .default_value(DEFAULT_TIME_RANGE) + .build(), + glib::ParamSpecBoolean::builder("flip-x").default_value(DEFAULT_FLIP_X).build(), + glib::ParamSpecBoolean::builder("flip-y").default_value(DEFAULT_FLIP_Y).build(), + glib::ParamSpecBoolean::builder("vertical") + .default_value(DEFAULT_VERTICAL) + .build(), + glib::ParamSpecEnum::builder::("type") + .default_value(RenderType::default()) + .build(), + glib::ParamSpecBoolean::builder("animate") + .default_value(DEFAULT_ANIMATE) + .build(), + ] + }); + PROPERTIES.as_ref() + } -// while let Some(entry) = history.front() { -// if last_updated_at.duration_since(entry.0).as_millis() as u64 -// > *self.time_range.borrow() -// { -// *last_value = history.pop_front(); -// } else { -// break; -// } -// } -// history.push_back(v); -// } -// /** -// * Receives normalized (0-1) coordinates `x` and `y` and convert them to the -// * point on the widget. -// */ -// fn value_to_point(&self, width: f64, height: f64, x: f64, y: f64) -> (f64, f64) { -// let x = if *self.flip_x.borrow() { 1.0 - x } else { x }; -// let y = if *self.flip_y.borrow() { 1.0 - y } else { y }; -// let (x, y) = if *self.vertical.borrow() { (y, x) } else { (x, y) }; -// (width * x, height * y) -// } -// } + fn set_property(&self, _: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let needs_path_rebuild = match pspec.name() { + "value" => { + let v: f64 = value.get().unwrap(); + self.value.set(v); -// impl ObjectImpl for GraphPriv { -// fn properties() -> &'static [glib::ParamSpec] { -// Self::derived_properties() -// } + // Don't record first value (default value) + if self.has_received_value.get() { + self.update_history((Instant::now(), v)); + } else { + self.has_received_value.replace(true); + } -// fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { -// match pspec.name() { -// "value" => { -// let value = value.get().unwrap(); -// self.value.replace(value); -// self.update_history((std::time::Instant::now(), value)); -// self.obj().queue_draw(); -// } -// "thickness" => { -// self.thickness.replace(value.get().unwrap()); -// } -// "max" => { -// self.max.replace(value.get().unwrap()); -// } -// "min" => { -// self.min.replace(value.get().unwrap()); -// } -// "dynamic" => { -// self.dynamic.replace(value.get().unwrap()); -// } -// "time-range" => { -// self.time_range.replace(value.get().unwrap()); -// } -// "line-style" => { -// self.line_style.replace(value.get().unwrap()); -// } -// "flip-x" => { -// self.flip_x.replace(value.get().unwrap()); -// } -// "flip-y" => { -// self.flip_y.replace(value.get().unwrap()); -// } -// "vertical" => { -// self.vertical.replace(value.get().unwrap()); -// } -// x => panic!("Tried to set inexistant property of Graph: {}", x,), -// } -// } + true + } + "thickness" => { + self.thickness.set(value.get().unwrap()); + false // Thickness doesn't affect path geometry + } + "line-style" => { + self.line_style.set(value.get::().unwrap()); + false // Line style doesn't affect path geometry + } + "min" => { + self.min.set(value.get().unwrap()); + true + } + "max" => { + self.max.set(value.get().unwrap()); + true + } + "dynamic" => { + self.dynamic.set(value.get().unwrap()); + true + } + "time-range" => { + self.time_range.set(value.get().unwrap()); + true + } + "flip-x" => { + self.flip_x.set(value.get().unwrap()); + true + } + "flip-y" => { + self.flip_y.set(value.get().unwrap()); + true + } + "vertical" => { + self.vertical.set(value.get().unwrap()); + true + } + "type" => { + self.render_type.set(value.get::().unwrap()); + true + } + "animate" => { + let animate = value.get().unwrap(); + self.set_animate(&self.obj(), animate); + false + } + x => panic!("Tried to set inexistent property of Graph: {}", x), + }; -// fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { -// self.derived_property(id, pspec) -// } -// } + if needs_path_rebuild { + self.cached_path.replace(None); + } -// #[object_subclass] -// impl ObjectSubclass for GraphPriv { -// type ParentType = gtk4::Bin; -// type Type = Graph; + // Queue redraw for any property change + self.obj().queue_draw(); + } -// const NAME: &'static str = "Graph"; + fn property(&self, _: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "value" => self.value.get().to_value(), + "thickness" => self.thickness.get().to_value(), + "line-style" => self.line_style.get().to_value(), + "min" => self.min.get().to_value(), + "max" => self.max.get().to_value(), + "dynamic" => self.dynamic.get().to_value(), + "time-range" => (self.time_range.get()).to_value(), + "flip-x" => self.flip_x.get().to_value(), + "flip-y" => self.flip_y.get().to_value(), + "vertical" => self.vertical.get().to_value(), + "type" => self.render_type.get().to_value(), + "animate" => self.is_animate().to_value(), + x => panic!("Tried to get inexistent property of Graph: {}", x), + } + } + } -// fn class_init(klass: &mut Self::Class) { -// klass.set_css_name("graph"); -// } -// } + struct GraphGeometry { + width: f32, + height: f32, + flip_x: bool, + flip_y: bool, + vertical: bool, + } -// impl Default for Graph { -// fn default() -> Self { -// Self::new() -// } -// } + struct GraphRange { + min: f64, + max: f64, + time_range: f32, + anchor_time: Instant, + } -// impl Graph { -// pub fn new() -> Self { -// glib::Object::new::() -// } -// } + struct GraphStyle { + render_type: RenderType, + thickness: f32, + line_style: LineStyle, + color: gdk::RGBA, + animate: bool, + } -// impl ContainerImpl for GraphPriv { -// fn add(&self, _widget: >k4::Widget) { -// error_handling_ctx::print_error(anyhow!("Error, Graph widget shoudln't have any children")); -// } -// } + impl WidgetImpl for Graph { + fn measure(&self, _orientation: gtk4::Orientation, _for_size: i32) -> (i32, i32, i32, i32) { + let t = self.thickness.get().max(1.0) as i32; + // min, natural, -, - + (t, t * 4, -1, -1) + } -// impl BinImpl for GraphPriv {} -// impl WidgetImpl for GraphPriv { -// fn preferred_width(&self) -> (i32, i32) { -// let thickness = *self.thickness.borrow() as i32; -// (thickness, thickness) -// } + /// Snapshot render nodes. + /// + /// Since this is potentially a hot code path, we want to keep it O(N) for maximum performance. + /// - min/max value calculation cached + /// - graph curve path cached + fn snapshot(&self, snapshot: >k4::Snapshot) { + let obj = self.obj(); + let history = self.history.borrow(); -// fn preferred_width_for_height(&self, height: i32) -> (i32, i32) { -// (height, height) -// } + if history.is_empty() { + return; + } -// fn preferred_height(&self) -> (i32, i32) { -// let thickness = *self.thickness.borrow() as i32; -// (thickness, thickness) -// } + // Margins + let margin_start = obj.margin_start() as f32; + let margin_end = obj.margin_end() as f32; + let margin_top = obj.margin_top() as f32; + let margin_bottom = obj.margin_bottom() as f32; -// fn preferred_height_for_width(&self, width: i32) -> (i32, i32) { -// (width, width) -// } + // Allocated size + let total_width = obj.width() as f32; + let total_height = obj.height() as f32; + let width = (total_width - margin_start - margin_end).max(0.0); + let height = (total_height - margin_top - margin_bottom).max(0.0); -// fn draw(&self, cr: &cairo::Context) -> glib::Propagation { -// let res: Result<()> = (|| { -// let history = &*self.history.borrow(); -// let extra_point = *self.extra_point.borrow(); + let geom = GraphGeometry { + width, + height, + flip_x: self.flip_x.get(), + flip_y: self.flip_y.get(), + vertical: self.vertical.get(), + }; -// // Calculate the max value -// let (min, max) = { -// let mut max = *self.max.borrow(); -// let min = *self.min.borrow(); -// let dynamic = *self.dynamic.borrow(); -// if dynamic { -// // Check for points higher than max -// for (_, value) in history { -// if *value > max { -// max = *value; -// } -// } -// if let Some((_, value)) = extra_point { -// if value > max { -// max = value; -// } -// } -// } -// (min, max) -// }; + let style = GraphStyle { + render_type: self.render_type.get(), + thickness: self.thickness.get() as f32, + line_style: self.line_style.get(), + color: obj.color(), + animate: self.is_animate(), + }; -// let styles = self.obj().style_context(); -// let (margin_top, margin_right, margin_bottom, margin_left) = { -// let margin = styles.margin(gtk4::StateFlags::NORMAL); -// (margin.top as f64, margin.right as f64, margin.bottom as f64, margin.left as f64) -// }; -// let width = self.obj().allocated_width() as f64 - margin_left - margin_right; -// let height = self.obj().allocated_height() as f64 - margin_top - margin_bottom; + let time_range_millis = self.time_range.get(); + if time_range_millis == 0 { + return; + } -// // Calculate graph points once -// // Separating this into another function would require pasing a -// // GraphPriv that would hide interior mutability -// let points = { -// let value_range = max - min; -// let time_range = *self.time_range.borrow() as f64; -// let last_updated_at = self.last_updated_at.borrow(); -// let mut points = history -// .iter() -// .map(|(instant, value)| { -// let t = last_updated_at.duration_since(*instant).as_millis() as f64; -// self.value_to_point( -// width, -// height, -// t / time_range, -// (value - min) / value_range, -// ) -// }) -// .collect::>(); + let render_time = Instant::now(); + let time_range = time_range_millis as f32; -// // Aad an extra point outside of the graph to extend the line to the left -// if let Some((instant, value)) = extra_point { -// let t = last_updated_at.duration_since(instant).as_millis() as f64; -// let (x, y) = self.value_to_point( -// width, -// height, -// (t - time_range) / time_range, -// (value - min) / value_range, -// ); -// points.push_front(if *self.vertical.borrow() { (x, -y) } else { (-x, y) }); -// } -// points -// }; + // Rebuild path? + let current_size = (width, height); + let mut cached_path = self.cached_path.borrow_mut(); + if cached_path.is_none() || self.cached_path_size.get() != current_size { + self.cached_path_size.set(current_size); + self.path_anchor_time.set(render_time); -// // Actually draw the graph -// cr.save()?; -// cr.translate(margin_left, margin_top); -// cr.rectangle(0.0, 0.0, width, height); -// cr.clip(); + let range = GraphRange { + min: self.min_value(), + max: self.max_value(), + time_range, + anchor_time: render_time, + }; -// // Draw Background -// let bg_color: gdk::RGBA = styles -// .style_property_for_state("background-color", gtk4::StateFlags::NORMAL) -// .get()?; -// if bg_color.alpha() > 0.0 { -// if let Some(first_point) = points.front() { -// cr.line_to(first_point.0, height + margin_bottom); -// } -// for (x, y) in points.iter() { -// cr.line_to(*x, *y); -// } -// cr.line_to(width, height); + let new_path = build_path(&history, &geom, &range, &style); + *cached_path = Some(new_path); + } -// cr.set_source_rgba( -// bg_color.red(), -// bg_color.green(), -// bg_color.blue(), -// bg_color.alpha(), -// ); -// cr.fill()?; -// } + // Draw the cached path with translation + snapshot.save(); + snapshot.translate(&graphene::Point::new(margin_start, margin_top)); + snapshot.push_clip(&graphene::Rect::new(0.0, 0.0, width, height)); -// // Draw Line -// let line_color: gdk::RGBA = styles.color(gtk4::StateFlags::NORMAL); -// let thickness = *self.thickness.borrow(); -// if line_color.alpha() > 0.0 && thickness > 0.0 { -// for (x, y) in points.iter() { -// cr.line_to(*x, *y); -// } + // Scroll animation + if style.animate && history.len() >= 2 { + // Shift by point interval to hide the gap between latest point and right edge + let interval = { + let t_old = history[history.len() - 2].0; + let t_new = history[history.len() - 1].0; + t_new.checked_duration_since(t_old).unwrap_or_default() + } + .as_millis() as f32; -// let line_style = &*self.line_style.borrow(); -// apply_line_style(line_style.as_str(), cr)?; -// cr.set_line_width(thickness); -// cr.set_source_rgba( -// line_color.red(), -// line_color.green(), -// line_color.blue(), -// line_color.alpha(), -// ); -// cr.stroke()?; -// } + // Calculate pixel shift based on time + let anchor_time = self.path_anchor_time.get(); + let time_shift = render_time.duration_since(anchor_time).as_millis() as f32; + let pixel_shift = (time_shift - interval) / time_range + * if geom.vertical { geom.height } else { geom.width } + * if geom.flip_x { 1.0 } else { -1.0 }; -// cr.reset_clip(); -// cr.restore()?; -// Ok(()) -// })(); + // Apply the camera translation + if geom.vertical { + snapshot.translate(&graphene::Point::new(0.0, pixel_shift)); + } else { + snapshot.translate(&graphene::Point::new(pixel_shift, 0.0)); + } + }; -// if let Err(error) = res { -// error_handling_ctx::print_error(error) -// }; + // Draw path + if let Some(path) = cached_path.as_ref() { + if matches!(style.render_type, RenderType::Line | RenderType::StepLine) { + // Render as stroked path + let stroke = gsk::Stroke::new(style.thickness); -// glib::Propagation::Proceed -// } -// } + // Configure line style + match style.line_style { + LineStyle::Miter => { + stroke.set_line_cap(gsk::LineCap::Butt); + stroke.set_line_join(gsk::LineJoin::Miter); + } + LineStyle::Bevel => { + stroke.set_line_cap(gsk::LineCap::Square); + stroke.set_line_join(gsk::LineJoin::Bevel); + } + LineStyle::Round => { + stroke.set_line_cap(gsk::LineCap::Round); + stroke.set_line_join(gsk::LineJoin::Round); + } + } -// fn apply_line_style(style: &str, cr: &cairo::Context) -> Result<()> { -// match style { -// "miter" => { -// cr.set_line_cap(cairo::LineCap::Butt); -// cr.set_line_join(cairo::LineJoin::Miter); -// } -// "bevel" => { -// cr.set_line_cap(cairo::LineCap::Square); -// cr.set_line_join(cairo::LineJoin::Bevel); -// } -// "round" => { -// cr.set_line_cap(cairo::LineCap::Round); -// cr.set_line_join(cairo::LineJoin::Round); -// } -// _ => Err(anyhow!("Error, the value: {} for atribute join is not valid", style))?, -// }; -// Ok(()) -// } + snapshot.append_stroke(path, &stroke, &style.color); + } else { + // Render as filled path + snapshot.append_fill(path, gsk::FillRule::Winding, &style.color); + } + } + + snapshot.pop(); // pop clip + snapshot.restore(); // restore translate + } + } + + impl Graph { + // Updates the history, removing points outside the range + fn update_history(&self, v: (Instant, f64)) { + let mut history = self.history.borrow_mut(); + let now = Instant::now(); + self.last_updated_at.set(now); + + let time_range_dur = Duration::from_millis(self.time_range.get().into()); + let visible_start = now + .checked_sub(time_range_dur) + .unwrap_or_else(|| history.front().map(|(t, _)| *t).unwrap_or(now)); + + // Animate: Need one extra point more left of the visible area to avoid showing a gap + let points_to_keep_off_canvas = if self.is_animate() { 2 } else { 1 }; + + // Prune history from the front (oldest). + while history.len() > points_to_keep_off_canvas { + // We check the timestamp of the point at index `points_to_keep_off_canvas`. + // If that point is outside the canvas, we can safely remove the oldest point (index 0). + if history[points_to_keep_off_canvas].0 < visible_start { + if let Some(val) = history.pop_front() { + // Value dropped: min/max values need recalc? + if self.min_value_cached.get().map_or(false, |min| min == val.1) { + self.min_value_cached.set(None); + } + if self.max_value_cached.get().map_or(false, |max| max == val.1) { + self.max_value_cached.set(None); + } + } + } else { + break; + } + } + + history.push_back(v); + + // New value: Update cached min/max value? + if let Some(min) = self.min_value_cached.get() { + self.min_value_cached.set(Some(min.min(v.1))); + } + if let Some(max) = self.max_value_cached.get() { + self.max_value_cached.set(Some(max.max(v.1))); + } + } + + fn set_animate(&self, obj: &super::Graph, animate: bool) { + let mut tick_id_storage = self.tick_id.borrow_mut(); + + if animate { + // If we want it ON, and it's currently None (OFF) + if tick_id_storage.is_none() { + let id = obj.add_tick_callback(|obj, _clock| { + obj.queue_draw(); + glib::ControlFlow::Continue + }); + *tick_id_storage = Some(id); + } + } else { + // We want it OFF + if let Some(id) = tick_id_storage.take() { + id.remove(); + } + } + } + + fn is_animate(&self) -> bool { + self.tick_id.borrow().is_some() + } + + fn min_value(&self) -> f64 { + if self.dynamic.get() { + self.min_value_cached.get().unwrap_or_else(|| { + // Calculate from history values + let history = self.history.borrow(); + let val = history.iter().fold(f64::INFINITY, |acc, &(_, v)| acc.min(v)); + self.min_value_cached.set(Some(val)); + val + }) + } else { + self.min.get() + } + } + + fn max_value(&self) -> f64 { + if self.dynamic.get() { + self.max_value_cached.get().unwrap_or_else(|| { + // Calculate from history values + let history = self.history.borrow(); + let val = history.iter().fold(f64::NEG_INFINITY, |acc, &(_, v)| acc.max(v)); + self.max_value_cached.set(Some(val)); + val + }) + } else { + self.max.get() + } + } + } + + /// Builds a path relative to a fixed anchor time + fn build_path( + points: &VecDeque<(Instant, f64)>, + geom: &GraphGeometry, + range: &GraphRange, + style: &GraphStyle, + ) -> gsk::Path { + if points.is_empty() { + return gsk::PathBuilder::new().to_path(); + } + + if range.time_range <= 0.0 { + return gsk::PathBuilder::new().to_path(); + } + + let builder = gsk::PathBuilder::new(); + let is_line = matches!(style.render_type, RenderType::Line | RenderType::StepLine); + let is_step = matches!(style.render_type, RenderType::StepLine | RenderType::StepFill); + + // Transform point relative to fixed anchor + let transform_point = |t_point: Instant, value: f64| -> (f32, f32) { + let t = range.anchor_time.duration_since(t_point).as_millis() as f32; + + let nx = t / range.time_range; + let ny = ((value - range.min) / (range.max - range.min).max(f64::EPSILON)) as f32; + + let x = if geom.flip_x { nx } else { 1.0 - nx }; + let y = if geom.flip_y { ny } else { 1.0 - ny }; + + if geom.vertical { + (y * geom.width, x * geom.height) + } else { + (x * geom.width, y * geom.height) + } + }; + + // Calculate first point coordinates + let &(t0, v0) = &points[0]; + let (mut last_x, mut last_y) = transform_point(t0, v0); + + let (base_x, base_y) = transform_point(range.anchor_time, range.min); + + if is_line { + // For lines: start at first point + builder.move_to(last_x, last_y); + } else { + // For fills: start at baseline then go to first point + if geom.vertical { + builder.move_to(base_x, last_y); + builder.line_to(last_x, last_y); + } else { + builder.move_to(last_x, base_y); + builder.line_to(last_x, last_y); + } + } + + // Draw the main graph line + for i in 1..points.len() { + let &(_, v_prev) = &points[i - 1]; + let &(t_curr, v_curr) = &points[i]; + + let (x_curr, y_curr) = transform_point(t_curr, v_curr); + + if is_step { + // Create horizontal step segment + let (x_step, y_step) = transform_point(t_curr, v_prev); + builder.line_to(x_step, y_step); + } + + builder.line_to(x_curr, y_curr); + last_x = x_curr; + last_y = y_curr; + } + + if is_line { + // For lines, we're done + builder.to_path() + } else { + // For fills, close the path to baseline + if geom.vertical { + builder.line_to(base_x, last_y); + } else { + builder.line_to(last_x, base_y); + } + builder.close(); + builder.to_path() + } + } +} + +#[derive(glib::Enum, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[enum_type(name = "EwwiiGraphLineStyle")] +pub enum LineStyle { + #[default] + Miter, + Bevel, + Round, +} + +#[derive(glib::Enum, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[enum_type(name = "EwwiiGraphRenderType")] +pub enum RenderType { + #[default] + Line, + StepLine, + Fill, + StepFill, +} + +// public wrapper +glib::wrapper! { + pub struct Graph(ObjectSubclass) + @extends gtk4::Widget, + @implements gtk4::Accessible, gtk4::Actionable, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Graph { + pub fn new() -> Self { + glib::Object::builder().build() + } +} diff --git a/crates/ewwii/src/widgets/widget_definitions.rs b/crates/ewwii/src/widgets/widget_definitions.rs index d442e54..740373e 100644 --- a/crates/ewwii/src/widgets/widget_definitions.rs +++ b/crates/ewwii/src/widgets/widget_definitions.rs @@ -28,6 +28,7 @@ use std::{ // custom widgets // use crate::widgets::{circular_progressbar::CircProg, transform::Transform}; use crate::widgets::circular_progressbar::CircProg; +use crate::widgets::graph::{Graph, RenderType}; /// Connect a gtk signal handler inside of this macro to ensure that when the same code gets run multiple times, /// the previously connected singal handler first gets disconnected. @@ -1336,93 +1337,121 @@ pub(super) fn build_circular_progress_bar( Ok(widget) } -// pub(super) fn build_graph( -// props: &Map, -// widget_registry: &mut WidgetRegistry, -// ) -> Result { -// let widget = super::graph::Graph::new(); +pub(super) fn build_graph(props: &Map, widget_registry: &mut WidgetRegistry) -> Result { + let widget = Graph::new(); -// let apply_props = |props: &Map, widget: &super::graph::Graph| -> Result<()> { -// if let Ok(value) = get_f64_prop(&props, "value", None) { -// if value.is_nan() || value.is_infinite() { -// return Err(anyhow!("Graph's value should never be NaN or infinite")); -// } -// widget.set_property("value", value); -// } + let apply_props = |props: &Map, widget: &Graph| -> Result<()> { + if let Ok(value) = get_f64_prop(&props, "value", None) { + if value.is_nan() || value.is_infinite() { + return Err(anyhow!("Graph's value should never be NaN or infinite")); + } + widget.set_property("value", value); + } -// if let Ok(thickness) = get_f64_prop(&props, "thickness", None) { -// widget.set_property("thickness", thickness); -// } + if let Ok(time_range) = get_duration_prop(&props, "time_range", None) { + let millis = time_range.as_millis(); + let millis_u32 = u32::try_from(millis).map_err(|_| { + anyhow!( + "Graph's time_range ({}ms) exceeds maximum representable ({}ms)", + millis, + u32::MAX + ) + })?; -// if let Ok(time_range) = get_duration_prop(&props, "time_range", None) { -// widget.set_property("time-range", time_range.as_millis() as u64); -// } + widget.set_property("time-range", millis_u32); + } -// let min = get_f64_prop(&props, "min", Some(0.0)).ok(); -// let max = get_f64_prop(&props, "max", Some(100.0)).ok(); + let min = get_f64_prop(&props, "min", Some(0.0)).ok(); + let max = get_f64_prop(&props, "max", Some(100.0)).ok(); -// if let (Some(mi), Some(ma)) = (min, max) { -// if mi > ma { -// return Err(anyhow!("Graph's min ({mi}) should never be higher than max ({ma})")); -// } -// } + if let (Some(min), Some(max)) = (min, max) { + if min > max { + return Err(anyhow!("Graph's min ({min}) should never be higher than max ({max})")); + } + } -// if let Some(mi) = min { -// widget.set_property("min", mi); -// } + if let Ok(dynamic) = get_bool_prop(&props, "dynamic", None) { + widget.set_property("dynamic", dynamic); + } -// if let Some(ma) = max { -// widget.set_property("max", ma); -// } + if let Some(min) = min { + widget.set_property("min", min); + } -// if let Ok(dynamic) = get_bool_prop(&props, "dynamic", None) { -// widget.set_property("dynamic", dynamic); -// } + if let Some(max) = max { + widget.set_property("max", max); + } -// if let Ok(line_style) = get_string_prop(&props, "line_style", None) { -// widget.set_property("line-style", line_style); -// } + if let Ok(render_type) = get_string_prop(&props, "type", None) { + match parse_graph_render_type(render_type.as_str()) { + Ok(t) => widget.set_property("type", t), + Err(e) => return Err(anyhow!("Failed to parse graph type property: {}", e)), + }; + } -// // flip-x - whether the x axis should go from high to low -// if let Ok(flip_x) = get_bool_prop(&props, "flip_x", None) { -// widget.set_property("flip-x", flip_x); -// } + if let Ok(thickness) = get_f64_prop(&props, "thickness", None) { + if !matches!(widget.property("type"), RenderType::Line | RenderType::StepLine) { + return Err(anyhow!("Property thickness can only be used with line graphs")); + } -// // flip-y - whether the y axis should go from high to low -// if let Ok(flip_y) = get_bool_prop(&props, "flip_y", None) { -// widget.set_property("flip-y", flip_y); -// } + widget.set_property("thickness", thickness); + } -// // vertical - if set to true, the x and y axes will be exchanged -// if let Ok(vertical) = get_bool_prop(&props, "vertical", None) { -// widget.set_property("vertical", vertical); -// } + if let Ok(line_style) = get_string_prop(&props, "line_style", None) { + if !matches!(widget.property("type"), RenderType::Line | RenderType::StepLine) { + return Err(anyhow!("Property line-style can only be used with line graphs")); + } -// Ok(()) -// }; + match parse_graph_line_style(line_style.as_str()) { + Ok(ls) => widget.set_property("line-style", ls), + Err(e) => return Err(anyhow!("Failed to parse graph line-style property: {}", e)), + }; + } -// apply_props(&props, &widget)?; + // flip-x - whether the x axis should go from high to low + if let Ok(flip_x) = get_bool_prop(&props, "flip_x", None) { + widget.set_property("flip-x", flip_x); + } -// let widget_clone = widget.clone(); -// let update_fn: UpdateFn = Box::new(move |props: &Map| { -// let _ = apply_props(props, &widget_clone); + // flip-y - whether the y axis should go from high to low + if let Ok(flip_y) = get_bool_prop(&props, "flip_y", None) { + widget.set_property("flip-y", flip_y); + } -// // now re-apply generic widget attrs -// if let Err(err) = -// resolve_rhai_widget_attrs(&widget_clone.clone().upcast::(), &props) -// { -// eprintln!("Failed to update widget attrs: {:?}", err); -// } -// }); + // vertical - if set to true, the x and y axes will be exchanged + if let Ok(vertical) = get_bool_prop(&props, "vertical", None) { + widget.set_property("vertical", vertical); + } -// let id = hash_props_and_type(&props, "Graph"); + if let Ok(animate) = get_bool_prop(&props, "animate", None) { + widget.set_property("animate", animate); + } -// widget_registry.widgets.insert(id, WidgetEntry { update_fn, widget: widget.clone().upcast() }); + Ok(()) + }; -// resolve_rhai_widget_attrs(&widget.clone().upcast::(), &props)?; + apply_props(&props, &widget)?; -// Ok(widget) -// } + let widget_clone = widget.clone(); + let update_fn: UpdateFn = Box::new(move |props: &Map| { + let _ = apply_props(props, &widget_clone); + + // now re-apply generic widget attrs + if let Err(err) = + resolve_rhai_widget_attrs(&widget_clone.clone().upcast::(), &props) + { + eprintln!("Failed to update widget attrs: {:?}", err); + } + }); + + let id = hash_props_and_type(&props, "Graph"); + + widget_registry.widgets.insert(id, WidgetEntry { update_fn, widget: widget.clone().upcast() }); + + resolve_rhai_widget_attrs(&widget.clone().upcast::(), &props)?; + + Ok(widget) +} pub(super) fn build_gtk_progress( props: &Map, diff --git a/crates/ewwii/src/widgets/widget_definitions_helper.rs b/crates/ewwii/src/widgets/widget_definitions_helper.rs index 7f45110..8ca345a 100644 --- a/crates/ewwii/src/widgets/widget_definitions_helper.rs +++ b/crates/ewwii/src/widgets/widget_definitions_helper.rs @@ -8,6 +8,8 @@ use gtk4::prelude::{Cast, ObjectExt, RangeExt, StaticType, ToValue}; use rhai::Map; use std::process::Command; +use crate::widgets::graph::{LineStyle, RenderType}; + // Run a command and get the output pub(super) fn run_command(timeout: std::time::Duration, cmd: &str, args: &[T]) where @@ -232,6 +234,27 @@ pub(super) fn parse_stack_transition(t: &str) -> Result Result { + match t.to_ascii_lowercase().as_str() { + "miter" => Ok(LineStyle::Miter), + "bevel" => Ok(LineStyle::Bevel), + "round" => Ok(LineStyle::Round), + _ => Err(anyhow!("Invalid graph line style: '{}'", t)), + } +} + +/// Graph render type +pub(super) fn parse_graph_render_type(t: &str) -> Result { + match t.to_ascii_lowercase().as_str() { + "line" => Ok(RenderType::Line), + "step-line" => Ok(RenderType::StepLine), + "fill" => Ok(RenderType::Fill), + "step-fill" => Ok(RenderType::StepFill), + _ => Err(anyhow!("Invalid graph render type: '{}'", t)), + } +} + // For localbind pub(super) fn set_property_from_string_anywhere( widget: >k4::Widget,