feat: add support for graph widget

This commit is contained in:
buzz
2025-12-20 10:55:38 +01:00
parent 8865fc0f6f
commit 2335ca5fe5
5 changed files with 734 additions and 376 deletions

View File

@@ -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

View File

@@ -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(),

View File

@@ -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<GraphPriv>)
// @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<f64>,
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<f64>,
pub struct Graph {
pub value: Cell<f64>,
pub thickness: Cell<f64>,
pub line_style: Cell<LineStyle>,
pub min: Cell<f64>,
pub max: Cell<f64>,
pub dynamic: Cell<bool>,
pub time_range: Cell<u32>,
pub flip_x: Cell<bool>,
pub flip_y: Cell<bool>,
pub vertical: Cell<bool>,
pub render_type: Cell<RenderType>,
// #[property(get, set, nick = "Line Style", blurb = "The Line Style", default = "miter")]
// line_style: RefCell<String>,
// Runtime state
history: RefCell<VecDeque<(Instant, f64)>>,
last_updated_at: RefCell<Instant>,
tick_id: RefCell<Option<gtk4::TickCallbackId>>,
min_value_cached: Cell<Option<f64>>,
max_value_cached: Cell<Option<f64>>,
has_received_value: Cell<bool>,
// #[property(get, set, nick = "Maximum Value", blurb = "The Maximum Value", minimum = 0f64, maximum = f64::MAX, default = 100f64)]
// min: RefCell<f64>,
// Cached path (Geometry)
cached_path: RefCell<Option<gsk::Path>>,
// The "anchor" time used to build the path (fixed time reference)
path_anchor_time: Cell<Instant>,
// 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<f64>,
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<bool>,
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<u64>,
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<bool>,
// #[property(get, set, nick = "Flip Y", blurb = "Flip the y axis", default = true)]
// flip_y: RefCell<bool>,
// #[property(get, set, nick = "Vertical", blurb = "Exchange the x and y axes", default = false)]
// vertical: RefCell<bool>,
#[glib::object_subclass]
impl ObjectSubclass for Graph {
const NAME: &'static str = "EwwiiGraph";
type Type = super::Graph;
type ParentType = gtk4::Widget;
}
// history: RefCell<VecDeque<(std::time::Instant, f64)>>,
// extra_point: RefCell<Option<(std::time::Instant, f64)>>,
// last_updated_at: RefCell<std::time::Instant>,
// }
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<Vec<glib::ParamSpec>> = 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::<LineStyle>("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::<RenderType>("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::<LineStyle>().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::<RenderType>().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::<Self>()
// }
// }
struct GraphStyle {
render_type: RenderType,
thickness: f32,
line_style: LineStyle,
color: gdk::RGBA,
animate: bool,
}
// impl ContainerImpl for GraphPriv {
// fn add(&self, _widget: &gtk4::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: &gtk4::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::<VecDeque<(f64, f64)>>();
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<imp::Graph>)
@extends gtk4::Widget,
@implements gtk4::Accessible, gtk4::Actionable, gtk4::Buildable, gtk4::ConstraintTarget;
}
impl Graph {
pub fn new() -> Self {
glib::Object::builder().build()
}
}

View File

@@ -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<super::graph::Graph> {
// let widget = super::graph::Graph::new();
pub(super) fn build_graph(props: &Map, widget_registry: &mut WidgetRegistry) -> Result<Graph> {
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::<gtk4::Widget>(), &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::<gtk4::Widget>(), &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::<gtk4::Widget>(), &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::<gtk4::Widget>(), &props)?;
Ok(widget)
}
pub(super) fn build_gtk_progress(
props: &Map,

View File

@@ -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<T>(timeout: std::time::Duration, cmd: &str, args: &[T])
where
@@ -232,6 +234,27 @@ pub(super) fn parse_stack_transition(t: &str) -> Result<gtk4::StackTransitionTyp
}
}
/// Graph line style
pub(super) fn parse_graph_line_style(t: &str) -> Result<LineStyle> {
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<RenderType> {
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: &gtk4::Widget,