Seeking advice for structuring a custom chart widget

First up, here’s a little gif of what I’ve got so far, and this post is specifically referring to the implementation of the chart widget in the top half of the screen.

Screencast from 2025-05-03 12-35-53

I initially implemented the chart as just a canvas with canvas::Program, but I wanted a bit more control over things so I’ve swapped over to impl’ing Widget instead.

I’ve sort of taken some inspiration from the TextEditor widget in terms of holding on to the data that the chart displays, so the widget’s new method takes a reference to a Data struct, which itself looks like this:

pub struct Data {
    traces: Vec<Vec<(u32, f32)>>,  // line-series data coordinates
    cache: canvas::Cache<Renderer>,
    traces_cache: canvas::Cache<Renderer>,
    tick_labels_overlay_cache: canvas::Cache<Renderer>,
}

Users can add/remove line-series data (“traces”) by using methods on the Data struct in their update function. Here’s a minimal example of how the widget would be used:

pub struct ChartScreen {
    chart_data: Data
}

impl ChartScreen {
    fn new() -> Self {
        // the parent screen just maintains the `Data` of the chart
        let chart_data = Data::new();

        Self {
            chart_data
        }
    }

    fn update(&mut self, message: Message) {
        match message {
            Message::ParameterChanged(new_param) => {
                let line_series = /compute_new_line_series_data/;
                // the user can set the data they want to display here
                self.chart_data.set_trace(line_series);
            }
        }
    }

    fn view(&self) -> Element<'a, Message> {
        // the widget itself is created in the `view` function, here, taking only a reference to
        // the data that it is displaying
        let chart = Chart::new(&self.chart_data);
        let form_for_configuring_parameters = ...;
        
        column![
            chart,
            form_for_configuring_parameters
        ]
    }
}

I only do minimal layouting in Widget::layout, which returns a Node defining the size of the whole widget, containing a single child Node representing the the actual plotting area within the x and y axes. The position of labels on the axes is calculated/hard-coded in the Widget::draw method. Furthermore, all drawing is done using Canvas::Cache s - you can see from my Data struct that I have split these for efficiency purposes - for example, when the user changes the parameters for the trace they want to display, the trace will update, but the static axes/labels on the chart remain cached.

I’m happy with the end result so far, but I am aware that my implementation is basically quite naive. I’m now looking to add more functionality to the widget, such as incorporating a button that will toggle the appearance of a secondary Y axis, making it possible to zoom/move the chart area, etc, but before I add too much extra complexity, I want to make sure I’m building on top of a reasonable framework.

In particular:

  1. Is doing all drawing using canvas::Caches a reasonable approach? At minimum I need to draw the line-series data on a Canvas because a bog standard renderer can only draw Quads and Text, but the rest doesn’t necessarily need to be a Canvas, I think.
  2. Is storing the canvas::Cache in the user-owned Data struct ok, or should I actually move that to an internal State struct within the widget? (the chart currently has an internal State, but I only use it for tracking cursor position at the moment).
  3. Is storing the user-defined line-series data on the Data struct the correct approach?
2 Likes
  1. Yes, that’s totally fine. I’m told Kraken Desktop uses canvas for all their charts, so if they can do it, so can you!

  2. Yes, that’s how game_of_life example does it. The internal state of the widget is for widget-specific data, such as the state of your click (whether you’re dragging, for instance), which persists across repeated calls to fn view. I strongly suggest going through that one in detail and maybe modifying a few things to see how it works, but at the same time you seem to have a pretty solid grasp already.

  3. Yes, widgets are pure so they don’t own or modify application state—they just publish Messages telling us how to modify app state in fn update. They can own their own state such as the current state of the interaction like I mentioned on #2. Sometimes the line between what is app state vs. widget state is blurry. To make up a bad example: are axis settings widget state or app state? It will depend on the specifics of the use case, although I’d be tempted to put it in app state, at the small cost of an additional “update burden” for the user.

Separately, see GitHub - kunerd/pliced for a recent project implementing charting based on canvas, in case you’d like to connect with them and contribute, or at least compare notes.

Thanks for the input, and you’ve given me a couple of good examples to take further inspiration from, too.

1 Like