use crate::{
core::i18n,
model::{ActivityInfo, ActivityType, FnBoxedTuple},
prelude::*,
};
use gtk::{gdk, gio::subclass::prelude::*, glib, pango, prelude::*};
use std::{collections::HashMap, convert::TryInto};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tuple {
pub activity_name: String,
pub calories: i64,
pub message: String,
}
#[derive(Clone, Debug)]
pub struct SplitBar {
pub date: glib::DateTime,
pub calorie_split: HashMap<ActivityType, i64>,
}
static HALF_X_PADDING: f32 = 40.0;
static HALF_Y_PADDING: f32 = 30.0;
mod imp {
use super::{Tuple, HALF_X_PADDING, HALF_Y_PADDING};
use crate::{
model::{ActivityInfo, ActivityType, FnBoxedTuple},
prelude::*,
views::SplitBar,
};
use gtk::{
gdk::prelude::*,
glib::{self, clone},
pango,
prelude::*,
subclass::prelude::*,
};
use std::{cell::RefCell, convert::TryInto, f64::consts::PI};
#[derive(Debug)]
pub struct HoverPoint {
pub data: Tuple,
pub x: f32,
pub y: f32,
}
pub struct BarGraphViewMut {
pub biggest_value: f32,
pub height: f32,
pub hover_func: Option<Box<dyn Fn(&Tuple) -> String>>,
pub hover_max_pointer_deviation: u32,
pub hover_point: Option<HoverPoint>,
pub limit: Option<f32>,
pub limit_label: Option<String>,
pub scale_x: f32,
pub scale_y: f32,
pub width: f32,
pub x_lines_interval: f32,
pub split_bars: Vec<SplitBar>,
pub rmr: f32,
}
pub struct BarGraphView {
pub inner: RefCell<BarGraphViewMut>,
}
#[glib::object_subclass]
impl ObjectSubclass for BarGraphView {
const NAME: &'static str = "HealthBarGraphView";
type ParentType = gtk::Widget;
type Type = super::BarGraphView;
fn new() -> Self {
Self {
inner: RefCell::new(BarGraphViewMut {
biggest_value: 0.1,
height: 0.0,
hover_func: None,
hover_max_pointer_deviation: 8,
hover_point: None,
limit: None,
limit_label: None,
scale_x: 0.0,
scale_y: 0.0,
width: 0.0,
x_lines_interval: 100.0,
split_bars: Vec::new(),
rmr: 0.0,
}),
}
}
fn class_init(klass: &mut Self::Class) {
klass.set_layout_manager_type::<gtk::BinLayout>();
}
}
impl WidgetImpl for BarGraphView {
fn snapshot(&self, snapshot: >k::Snapshot) {
let mut inner = self.inner.borrow_mut();
let widget = self.obj();
inner.height = widget.height() as f32 - HALF_Y_PADDING * 2.0;
inner.width = widget.width() as f32
- HALF_X_PADDING * if inner.split_bars.len() > 1 { 5.0 } else { 2.0 };
let biggest_value = if inner.split_bars.is_empty() {
inner.scale_x = inner.width;
inner.scale_y = inner.height / 10000.0;
0.1
} else {
let biggest_value = inner.biggest_value + inner.x_lines_interval
- inner.biggest_value % inner.x_lines_interval;
inner.scale_x = if inner.split_bars.len() > 1 {
inner.width / (inner.split_bars.len() - 1) as f32
} else {
inner.width
};
inner.scale_y = inner.height / biggest_value;
biggest_value
};
let cr = snapshot.append_cairo(>k::graphene::Rect::new(
0.0,
0.0,
widget.width() as f32,
widget.height() as f32,
));
let style_context = widget.style_context();
let background_color = style_context.lookup_color("insensitive_fg_color").unwrap();
GdkCairoContextExt::set_source_rgba(&cr, &background_color);
cr.save().unwrap();
cr.set_line_width(0.5);
cr.set_dash(&[10.0, 5.0], 0.0);
for i in 0..4 {
let mul = inner.height / 4.0;
cr.move_to(
f64::from(inner.width + 4.0 * HALF_X_PADDING * 2.0),
f64::from(mul * i as f32 + HALF_Y_PADDING),
);
cr.line_to(
f64::from(HALF_X_PADDING),
f64::from(mul * i as f32 + HALF_Y_PADDING),
);
let layout = widget.create_pango_layout(Some(
&((biggest_value / 4.0 * (4 - i) as f32) as u32).to_string(),
));
let (_, extents) = layout.extents();
cr.rel_move_to(0.0, pango::units_to_double(extents.height()) * -1.0);
pangocairo::show_layout(&cr, &layout);
}
cr.stroke().expect("Couldn't stroke on Cairo Context");
cr.restore().unwrap();
cr.save().unwrap();
for (i, bar) in inner.split_bars.iter().enumerate() {
let layout = widget.create_pango_layout(Some(&bar.date.format_local()));
let (_, extents) = layout.extents();
cr.move_to(
f64::from(i as f32 * inner.scale_x + HALF_X_PADDING * 2.0)
- pango::units_to_double(extents.width()) / 2.0,
f64::from(inner.height + HALF_Y_PADDING * 1.5)
- pango::units_to_double(extents.height()) / 2.0,
);
pangocairo::show_layout(&cr, &layout);
}
cr.stroke().expect("Couldn't stroke on Cairo Context");
cr.restore().unwrap();
cr.save().unwrap();
cr.set_line_width(0.1);
for (i, split_bar) in inner.split_bars.iter().enumerate() {
let mut sorted_by_calories = split_bar
.calorie_split
.clone()
.into_iter()
.map(|(id, calorie)| (id, calorie))
.collect::<Vec<(ActivityType, i64)>>();
sorted_by_calories.sort_by(|a, b| b.1.cmp(&a.1));
let scroll_thickness = 2.0;
let x = f64::from(i as f32 * inner.scale_x + HALF_X_PADDING);
let height = if inner.rmr != 0.0 {
f64::from(inner.rmr * inner.scale_y)
} else {
20.0
};
let mut bar_top =
f64::from(inner.height + HALF_Y_PADDING) - height - scroll_thickness;
GdkCairoContextExt::set_source_rgba(
&cr,
>k::gdk::RGBA::builder()
.red(0.0)
.blue(0.0)
.green(0.0)
.alpha(1.0)
.build(),
);
cr.move_to(x + f64::from(HALF_X_PADDING), bar_top);
cr.rectangle(f64::from(HALF_X_PADDING) + x - 10.0, bar_top, 20.0, height);
cr.stroke_preserve()
.expect("Couldn't stroke on Cairo Context");
cr.fill().expect("Couldn't fill on Cairo Context");
for (activity_id, calories) in sorted_by_calories {
GdkCairoContextExt::set_source_rgba(
&cr,
&ActivityInfo::from(activity_id).color,
);
let calories = calories as f32;
bar_top -= f64::from(calories * inner.scale_y);
cr.move_to(x + f64::from(HALF_X_PADDING), bar_top);
cr.rectangle(
f64::from(HALF_X_PADDING) + x - 10.0,
bar_top,
20.0,
f64::from(calories * inner.scale_y),
);
cr.stroke_preserve()
.expect("Couldn't stroke on Cairo Context");
cr.fill().expect("Couldn't fill on Cairo Context");
}
}
cr.stroke().expect("Couldn't stroke on Cairo Context");
cr.restore().unwrap();
if let Some(hover_func) = &inner.hover_func {
if let Some(hover_point) = &inner.hover_point {
let layout = widget.create_pango_layout(Some(&hover_func(&Tuple {
activity_name: hover_point.data.activity_name.to_string(),
calories: hover_point.data.calories,
message: hover_point.data.message.to_string(),
})));
let (_, extents) = layout.extents();
let radius = pango::units_to_double(extents.height()) / 5.0;
let degrees = PI / 180.0;
let padding = 12.0;
let x_delta = if (hover_point.x
+ pango::units_to_double(extents.width()) as f32
+ padding * 2.0)
> inner.width
{
(pango::units_to_double(extents.width()) as f32 + padding * 3.0) * -1.0
} else {
0.0
};
cr.new_sub_path();
cr.arc(
f64::from(hover_point.x + padding * 2.0 + x_delta)
+ pango::units_to_double(extents.width())
- radius,
f64::from(hover_point.y - padding / 2.0)
- pango::units_to_double(extents.height()) / 2.0
+ radius,
radius,
-90.0 * degrees,
0.0,
);
cr.arc(
f64::from(hover_point.x + padding * 2.0 + x_delta)
+ pango::units_to_double(extents.width())
- radius,
f64::from(hover_point.y + padding / 2.0)
+ pango::units_to_double(extents.height()) / 2.0
- radius,
radius,
0.0,
90.0 * degrees,
);
cr.arc(
f64::from(hover_point.x + padding + x_delta) + radius,
f64::from(hover_point.y + padding / 2.0)
+ pango::units_to_double(extents.height()) / 2.0
- radius,
radius,
90.0 * degrees,
180.0 * degrees,
);
cr.arc(
f64::from(hover_point.x + padding + x_delta) + radius,
f64::from(hover_point.y - padding / 2.0)
- pango::units_to_double(extents.height()) / 2.0
+ radius,
radius,
180.0 * degrees,
270.0 * degrees,
);
cr.close_path();
cr.set_source_rgba(0.0, 0.0, 0.0, 0.65);
cr.fill_preserve().expect("Couldn't fill Cairo Context");
cr.move_to(
f64::from(hover_point.x + padding * 1.5 + x_delta),
f64::from(hover_point.y) - pango::units_to_double(extents.height()) / 2.0,
);
cr.set_source_rgba(1.0, 1.0, 1.0, 1.0);
pangocairo::show_layout(&cr, &layout);
cr.stroke().expect("Couldn't stroke on Cairo Context");
}
}
}
}
impl ObjectImpl for BarGraphView {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
obj.set_hexpand(true);
obj.set_vexpand(true);
let gesture_controller = gtk::GestureClick::new();
gesture_controller.set_touch_only(true);
gesture_controller.connect_pressed(
clone!(@weak obj => move |c, _, x, y| obj.on_motion_event(x, y, true, c)),
);
obj.add_controller(gesture_controller);
let motion_controller = gtk::EventControllerMotion::new();
motion_controller.connect_enter(
clone!(@weak obj => move|c, x, y| obj.on_motion_event(x, y, false, c)),
);
motion_controller.connect_motion(
clone!(@weak obj => move|c, x, y| obj.on_motion_event(x, y, false, c)),
);
obj.add_controller(motion_controller);
let mut inner = self.inner.borrow_mut();
inner.hover_max_pointer_deviation = (8 * obj.scale_factor()).try_into().unwrap();
}
fn properties() -> &'static [glib::ParamSpec] {
use once_cell::sync::Lazy;
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecBoxed::builder::<FnBoxedTuple>("hover-func")
.write_only()
.build(),
glib::ParamSpecFloat::builder("rmr")
.minimum(0.0)
.blurb("Resting Metabolic Rate")
.build(),
glib::ParamSpecFloat::builder("x-lines-interval")
.minimum(0.0)
.build(),
]
});
PROPERTIES.as_ref()
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
let obj = self.obj();
match pspec.name() {
"hover-func" => {
self.inner.borrow_mut().hover_func =
value.get::<FnBoxedTuple>().unwrap().0.borrow_mut().take()
}
"rmr" => {
self.inner.borrow_mut().rmr = value.get().unwrap();
obj.queue_draw();
}
"x-lines-interval" => {
self.inner.borrow_mut().x_lines_interval = value.get().unwrap();
obj.queue_draw();
}
_ => unimplemented!(),
}
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"rmr" => self.inner.borrow().rmr.to_value(),
"x-lines-interval" => self.inner.borrow().x_lines_interval.to_value(),
_ => unimplemented!(),
}
}
}
}
glib::wrapper! {
pub struct BarGraphView(ObjectSubclass<imp::BarGraphView>)
@extends gtk::Widget,
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
}
impl BarGraphView {
pub fn new() -> Self {
glib::Object::new()
}
pub fn rmr(&self) -> f32 {
self.property("rmr")
}
pub fn set_hover_func(&self, hover_func: Option<Box<dyn Fn(&Tuple) -> String>>) {
self.set_property("hover-func", FnBoxedTuple::new(hover_func))
}
pub fn set_rmr(&self, rmr: f32) {
self.set_property("rmr", rmr);
}
pub fn set_split_bars(&self, split_bars: Vec<SplitBar>) {
let layout = self.create_pango_layout(Some(&glib::DateTime::local().format_local()));
let (_, extents) = layout.extents();
let datapoint_width = pango::units_to_double(extents.width()) + f64::from(HALF_X_PADDING);
self.set_size_request(
(datapoint_width as usize * split_bars.len())
.try_into()
.unwrap(),
-1,
);
let mut inner = self.imp().inner.borrow_mut();
inner.split_bars = split_bars.clone();
inner.split_bars.sort_by(|a, b| a.date.cmp(&b.date));
let total_calories = |calorie_split: &HashMap<ActivityType, i64>| -> i64 {
calorie_split.iter().map(|b| b.1).sum()
};
inner.biggest_value = split_bars
.iter()
.max_by(|x, y| {
(total_calories(&x.calorie_split)).cmp(&(total_calories(&y.calorie_split)))
})
.map(|b| total_calories(&b.calorie_split))
.unwrap() as f32
+ inner.rmr;
if inner.biggest_value < inner.limit.unwrap_or(0.0) {
inner.biggest_value = inner.limit.unwrap();
}
self.queue_draw();
}
pub fn set_x_lines_interval(&self, x_lines_interval: f32) {
self.set_property("x-lines-interval", x_lines_interval);
}
pub fn x_lines_interval(&self) -> f32 {
self.property("x-lines-interval")
}
fn on_motion_event(
&self,
x: f64,
y: f64,
allow_touch: bool,
controller: &impl IsA<gtk::EventController>,
) {
let mut inner = self.imp().inner.borrow_mut();
if !allow_touch {
if let Some(device) = controller.current_event_device() {
if device.source() == gdk::InputSource::Touchscreen {
return;
}
}
}
let mut segment = None;
let mut bar_index = None;
for i in 0..inner.split_bars.len() {
let point_x = i as f32 * inner.scale_x + 2.0 * HALF_X_PADDING;
if (point_x - x as f32).abs() <= 10.0 {
bar_index = Some(i);
break;
}
}
if let Some(index) = bar_index {
let touched_bar = inner.split_bars[index].clone();
let mut sorted_by_calories = touched_bar
.calorie_split
.into_iter()
.map(|(id, calorie)| (id, calorie))
.collect::<Vec<(ActivityType, i64)>>();
sorted_by_calories.sort_by(|a, b| b.1.cmp(&a.1));
let height = if inner.rmr != 0.0 {
f64::from(inner.rmr * inner.scale_y)
} else {
20.0
};
let mut cursor_height = f64::from(inner.height + HALF_Y_PADDING) - y - height;
if cursor_height >= 0.0 {
for (id, calories) in sorted_by_calories {
cursor_height -= f64::from(calories as f32 * inner.scale_y);
if cursor_height < 0.0 {
segment = Some(imp::HoverPoint {
data: Tuple {
activity_name: ActivityInfo::from(id).name,
calories,
message: "".to_string(),
},
x: x as f32,
y: y as f32,
});
break;
}
}
} else if cursor_height >= -height {
let message = if inner.rmr != 0.0 {
String::new()
} else {
i18n("Enter weight record to calculate idle calories")
};
segment = Some(imp::HoverPoint {
data: Tuple {
activity_name: i18n("Idle calories"),
calories: inner.rmr as i64,
message,
},
x: x as f32,
y: y as f32,
});
}
}
inner.hover_point = segment;
self.queue_draw();
}
}
#[cfg(test)]
mod test {
use super::BarGraphView;
use crate::utils::init_gtk;
#[gtk::test]
fn new() {
init_gtk();
BarGraphView::new();
}
#[gtk::test]
fn properties() {
init_gtk();
let b = BarGraphView::new();
b.set_rmr(b.rmr());
b.set_x_lines_interval(b.x_lines_interval());
b.set_hover_func(None);
}
}