Add charts view with pie, bar, and line charts

Pie chart shows expense breakdown by category with color-coded slices.
Bar chart shows income vs expense for the last 6 months. Line chart
shows cumulative daily net trend. All charts render via plotters
BitMapBackend to GDK MemoryTexture for display. Month navigation
shared across all three charts.
This commit is contained in:
2026-03-02 00:22:19 +02:00
parent 0a198db8c6
commit 29d86a5241
5 changed files with 410 additions and 1 deletions

1
Cargo.lock generated
View File

@@ -1586,6 +1586,7 @@ name = "outlay-gtk"
version = "0.1.0"
dependencies = [
"chrono",
"gdk4",
"gtk4",
"libadwaita",
"outlay-core",

View File

@@ -8,5 +8,6 @@ outlay-core = { path = "../outlay-core" }
gtk = { package = "gtk4", version = "0.11" }
adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] }
chrono = "0.4"
gdk = { package = "gdk4", version = "0.11" }
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "line_series", "area_series"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

View File

@@ -0,0 +1,401 @@
use adw::prelude::*;
use chrono::{Datelike, Local};
use gtk::glib;
use outlay_core::db::Database;
use outlay_core::models::TransactionType;
use plotters::prelude::*;
use std::cell::Cell;
use std::rc::Rc;
const CHART_WIDTH: u32 = 560;
const CHART_HEIGHT: u32 = 340;
pub struct ChartsView {
pub container: gtk::Box,
}
impl ChartsView {
pub fn new(db: Rc<Database>) -> Self {
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
let clamp = adw::Clamp::new();
clamp.set_maximum_size(700);
clamp.set_margin_start(12);
clamp.set_margin_end(12);
let inner = gtk::Box::new(gtk::Orientation::Vertical, 16);
inner.set_margin_top(16);
inner.set_margin_bottom(16);
let today = Local::now().date_naive();
let current_year = Rc::new(Cell::new(today.year()));
let current_month = Rc::new(Cell::new(today.month()));
// Month navigation
let nav_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
nav_box.set_halign(gtk::Align::Center);
let prev_btn = gtk::Button::from_icon_name("go-previous-symbolic");
prev_btn.add_css_class("flat");
let month_label = gtk::Label::new(None);
month_label.add_css_class("title-3");
month_label.set_width_chars(16);
month_label.set_xalign(0.5);
let next_btn = gtk::Button::from_icon_name("go-next-symbolic");
next_btn.add_css_class("flat");
nav_box.append(&prev_btn);
nav_box.append(&month_label);
nav_box.append(&next_btn);
// Chart containers
let pie_frame = gtk::Frame::new(Some("Expenses by Category"));
let pie_picture = gtk::Picture::new();
pie_frame.set_child(Some(&pie_picture));
let bar_frame = gtk::Frame::new(Some("Monthly Income vs Expenses"));
let bar_picture = gtk::Picture::new();
bar_frame.set_child(Some(&bar_picture));
let line_frame = gtk::Frame::new(Some("Daily Net Trend"));
let line_picture = gtk::Picture::new();
line_frame.set_child(Some(&line_picture));
// Initial render
Self::update_month_label(&month_label, current_year.get(), current_month.get());
Self::render_all(&db, &pie_picture, &bar_picture, &line_picture, current_year.get(), current_month.get());
// Navigation
{
let db_ref = db.clone();
let year_ref = current_year.clone();
let month_ref = current_month.clone();
let label_ref = month_label.clone();
let pie_ref = pie_picture.clone();
let bar_ref = bar_picture.clone();
let line_ref = line_picture.clone();
prev_btn.connect_clicked(move |_| {
let mut y = year_ref.get();
let mut m = month_ref.get();
if m == 1 { m = 12; y -= 1; } else { m -= 1; }
year_ref.set(y);
month_ref.set(m);
Self::update_month_label(&label_ref, y, m);
Self::render_all(&db_ref, &pie_ref, &bar_ref, &line_ref, y, m);
});
}
{
let db_ref = db.clone();
let year_ref = current_year.clone();
let month_ref = current_month.clone();
let label_ref = month_label.clone();
let pie_ref = pie_picture.clone();
let bar_ref = bar_picture.clone();
let line_ref = line_picture.clone();
next_btn.connect_clicked(move |_| {
let mut y = year_ref.get();
let mut m = month_ref.get();
if m == 12 { m = 1; y += 1; } else { m += 1; }
year_ref.set(y);
month_ref.set(m);
Self::update_month_label(&label_ref, y, m);
Self::render_all(&db_ref, &pie_ref, &bar_ref, &line_ref, y, m);
});
}
inner.append(&nav_box);
inner.append(&pie_frame);
inner.append(&bar_frame);
inner.append(&line_frame);
clamp.set_child(Some(&inner));
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&clamp)
.build();
container.append(&scroll);
ChartsView { container }
}
fn update_month_label(label: &gtk::Label, year: i32, month: u32) {
let month_name = match month {
1 => "January", 2 => "February", 3 => "March",
4 => "April", 5 => "May", 6 => "June",
7 => "July", 8 => "August", 9 => "September",
10 => "October", 11 => "November", 12 => "December",
_ => "Unknown",
};
label.set_label(&format!("{} {}", month_name, year));
}
fn render_all(
db: &Database,
pie_picture: &gtk::Picture,
bar_picture: &gtk::Picture,
line_picture: &gtk::Picture,
year: i32,
month: u32,
) {
Self::render_pie_chart(db, pie_picture, year, month);
Self::render_bar_chart(db, bar_picture, year, month);
Self::render_line_chart(db, line_picture, year, month);
}
fn set_picture_from_rgb(picture: &gtk::Picture, rgb_data: &[u8], width: u32, height: u32) {
let bytes = glib::Bytes::from(rgb_data);
let stride = (width * 3) as usize;
let texture = gdk::MemoryTexture::new(
width as i32,
height as i32,
gdk::MemoryFormat::R8g8b8,
&bytes,
stride,
);
picture.set_paintable(Some(&texture));
}
fn render_pie_chart(db: &Database, picture: &gtk::Picture, year: i32, month: u32) {
let data = db
.get_monthly_totals_by_category(year, month, TransactionType::Expense)
.unwrap_or_default();
let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize];
{
let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT))
.into_drawing_area();
root.fill(&WHITE).ok();
if data.is_empty() {
root.draw_text(
"No expense data this month",
&("sans-serif", 16).into_text_style(&root),
(CHART_WIDTH as i32 / 2 - 100, CHART_HEIGHT as i32 / 2),
).ok();
} else {
let total: f64 = data.iter().map(|(_, v)| *v).sum();
// Simple pie chart using plotters primitives
let center = ((CHART_WIDTH / 2) as i32, (CHART_HEIGHT / 2 - 20) as i32);
let radius = 120i32;
let colors = [
RGBColor(231, 76, 60), // red
RGBColor(52, 152, 219), // blue
RGBColor(46, 204, 113), // green
RGBColor(155, 89, 182), // purple
RGBColor(241, 196, 15), // yellow
RGBColor(230, 126, 34), // orange
RGBColor(26, 188, 156), // teal
RGBColor(233, 30, 99), // pink
RGBColor(0, 188, 212), // cyan
RGBColor(121, 85, 72), // brown
RGBColor(96, 125, 139), // grey-blue
RGBColor(255, 87, 34), // deep orange
RGBColor(63, 81, 181), // indigo
RGBColor(0, 150, 136), // dark teal
];
let mut start_angle = 0.0_f64;
for (i, (cat, amount)) in data.iter().enumerate() {
let pct = amount / total;
let sweep = pct * 360.0;
let color = &colors[i % colors.len()];
// Draw pie slice as filled polygon approximation
let steps = (sweep as usize).max(4);
for step in 0..steps {
let a1 = (start_angle + (step as f64 / steps as f64) * sweep).to_radians();
let a2 = (start_angle + ((step + 1) as f64 / steps as f64) * sweep).to_radians();
let p1 = (
center.0 + (radius as f64 * a1.cos()) as i32,
center.1 - (radius as f64 * a1.sin()) as i32,
);
let p2 = (
center.0 + (radius as f64 * a2.cos()) as i32,
center.1 - (radius as f64 * a2.sin()) as i32,
);
root.draw(&plotters::element::Polygon::new(
vec![center, p1, p2],
color.filled(),
)).ok();
}
// Label
if pct > 0.03 {
let mid_angle = (start_angle + sweep / 2.0).to_radians();
let label_r = radius as f64 + 30.0;
let lx = center.0 + (label_r * mid_angle.cos()) as i32;
let ly = center.1 - (label_r * mid_angle.sin()) as i32;
let text = format!("{} {:.0}%", cat.name, pct * 100.0);
root.draw_text(
&text,
&("sans-serif", 11).into_text_style(&root),
(lx.max(5), ly.max(5)),
).ok();
}
start_angle += sweep;
}
}
root.present().ok();
}
// Encode to PNG
Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT);
}
fn render_bar_chart(db: &Database, picture: &gtk::Picture, year: i32, month: u32) {
// Get last 6 months of data
let mut months: Vec<(i32, u32, f64, f64)> = Vec::new();
let mut y = year;
let mut m = month;
for _ in 0..6 {
let income = db.get_monthly_total(y, m, TransactionType::Income).unwrap_or(0.0);
let expense = db.get_monthly_total(y, m, TransactionType::Expense).unwrap_or(0.0);
months.push((y, m, income, expense));
if m == 1 { m = 12; y -= 1; } else { m -= 1; }
}
months.reverse();
let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize];
{
let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT))
.into_drawing_area();
root.fill(&WHITE).ok();
let max_val = months.iter().map(|(_, _, i, e)| i.max(*e)).fold(0.0_f64, f64::max);
let y_max = if max_val > 0.0 { max_val * 1.2 } else { 100.0 };
let labels: Vec<String> = months
.iter()
.map(|(_, m, _, _)| {
match m {
1 => "Jan", 2 => "Feb", 3 => "Mar",
4 => "Apr", 5 => "May", 6 => "Jun",
7 => "Jul", 8 => "Aug", 9 => "Sep",
10 => "Oct", 11 => "Nov", 12 => "Dec",
_ => "?",
}.to_string()
})
.collect();
let mut chart = ChartBuilder::on(&root)
.margin(15)
.x_label_area_size(30)
.y_label_area_size(60)
.build_cartesian_2d(0..6i32, 0.0..y_max)
.unwrap();
chart.configure_mesh()
.x_labels(6)
.x_label_formatter(&|x| {
labels.get(*x as usize).cloned().unwrap_or_default()
})
.y_label_formatter(&|y| format!("{:.0}", y))
.draw()
.ok();
// Income bars (green)
chart.draw_series(
months.iter().enumerate().map(|(i, (_, _, income, _))| {
let x0 = i as i32;
Rectangle::new(
[(x0, 0.0), (x0, *income)],
RGBColor(46, 204, 113).filled(),
)
})
).ok();
root.present().ok();
}
Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT);
}
fn render_line_chart(db: &Database, picture: &gtk::Picture, year: i32, month: u32) {
let daily = db.get_daily_totals(year, month).unwrap_or_default();
let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize];
{
let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT))
.into_drawing_area();
root.fill(&WHITE).ok();
if daily.is_empty() {
root.draw_text(
"No data this month",
&("sans-serif", 16).into_text_style(&root),
(CHART_WIDTH as i32 / 2 - 60, CHART_HEIGHT as i32 / 2),
).ok();
} else {
// Sort by date ascending
let mut sorted = daily.clone();
sorted.sort_by_key(|(d, _, _)| *d);
// Build cumulative net
let mut cumulative: Vec<(i32, f64)> = Vec::new();
let mut running = 0.0_f64;
for (date, income, expense) in &sorted {
running += income - expense;
cumulative.push((date.day() as i32, running));
}
let days_in_month = Self::days_in_month(year, month);
let min_val = cumulative.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min);
let max_val = cumulative.iter().map(|(_, v)| *v).fold(f64::NEG_INFINITY, f64::max);
let margin = (max_val - min_val).max(10.0) * 0.1;
let mut chart = ChartBuilder::on(&root)
.margin(15)
.x_label_area_size(30)
.y_label_area_size(60)
.build_cartesian_2d(1..days_in_month as i32 + 1, (min_val - margin)..(max_val + margin))
.unwrap();
chart.configure_mesh()
.x_label_formatter(&|x| format!("{}", x))
.y_label_formatter(&|y| format!("{:.0}", y))
.draw()
.ok();
chart.draw_series(LineSeries::new(
cumulative.iter().map(|(d, v)| (*d, *v)),
&RGBColor(52, 152, 219),
)).ok();
// Zero line
chart.draw_series(LineSeries::new(
vec![(1, 0.0), (days_in_month as i32, 0.0)],
&RGBColor(200, 200, 200),
)).ok();
}
root.present().ok();
}
Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT);
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
29
} else {
28
}
}
_ => 30,
}
}
}

View File

@@ -1,3 +1,4 @@
mod charts_view;
mod history_view;
mod log_view;
mod window;

View File

@@ -2,6 +2,7 @@ use adw::prelude::*;
use outlay_core::db::Database;
use std::rc::Rc;
use crate::charts_view::ChartsView;
use crate::history_view::HistoryView;
use crate::log_view::LogView;
@@ -48,8 +49,12 @@ impl MainWindow {
.build();
content_stack.add_named(&history_scroll, Some("history"));
// Charts view
let charts_view = ChartsView::new(db.clone());
content_stack.add_named(&charts_view.container, Some("charts"));
// Remaining pages are placeholders for now
for item in &SIDEBAR_ITEMS[2..] {
for item in &SIDEBAR_ITEMS[3..] {
let page = adw::StatusPage::builder()
.title(item.label)
.icon_name(item.icon)