Writing similar code for each field in struct without derive

I am using ratatui to build a basic TUI for an application and I need to provide a Block for each field in a struct to display it properly and allow for user input.

I found the crate rust_struct_iterable however that only has immutable access to fields, and also even the pull request to implement mutable access relies on dyn and runtime lookups. Ideally I would like to avoid this runtime type checking and rely on the compiler type checking.

From what I have read thus far, it would be best to create a function-like procedural macro that can generate the code for me, but I have not figured out how to access struct fields within a function macro. If I used a derive macro, I am not sure how to place the generated code where I need, as I wouldn't be implementing a trait.

I feel like this is a pattern that I have encountered a lot, and feel like there should be a better/easier way since there is a lot of resistance from the type system.

I could use a HashMap full of Box<T>s instead of the struct, but then I lose out on Serde functionality, and make the code less readable and Correct.

Thanks in advance.

it's not clear what you actually want to achieve. better to provide a basic example, including what's the expected syntax to use the macro, and what the generated code should look like.

you don't need to implement a trait, you can just implement inherent methods for the annotated type.

2 Likes

Essentially I have a TUI where I need to be able to display and edit the values of each field in the struct.

  • I want to be able to create the visual layout for the fields in a loop
    • I need to access the name of the fields to set the title of the visual subelements, and be able to manipulate it with commands like to_ascii_uppercase etc
    • I need to access the value of the field in order to update the sub element valuue
  • I need to be able to match on each field and get a mutable reference to each field within the match so I can update the correct field value when handling keyboard input.

I have a struct like so (in the actual project there are maybe 4-5 of these structs, that each need similar things implemented):

/// `Recipe` represents one recipe from start to finish
#[derive(Default, Debug, Clone, PartialEq)]
pub struct Recipe {
    /// database ID
    pub id: u64,
    /// short name of recipe
    pub name: String,
    /// optional description
    pub description: Option<String>,
    /// recipe comments
    pub comments: Option<String>,
    /// recipe source
    pub source: String,
    /// recipe author
    pub author: String,
    /// amount made
    pub amount_made: u64,
    /// units for amount made.
    ///
    /// These are not type checked at all and are treated as a base quantity internally.
    /// This is just a representation of the units to display.
    /// There may be a future addition that automatically calculates calories, or serving
    /// sizes based on calories.
    pub amount_made_units: String,
    /// list of steps in recipe
    pub steps: Vec<Step>,
    /// list of tags on recipe
    pub tags: Vec<Tag>,
    //TODO: versions
    /// if the recipe has unsaved changes or not
    //TODO: figure out a save system
    pub saved: bool,
}

and I want to do something like the following, ideally in the same order for each of the for loops. (I have omitted some of the intermediate code for brevity). Some of the fields like steps and tags would be skipped by a skip attribute since those will be handled separately.

The below would ideally be generated by something that looks like generate_constraints!(struct), with constraints defined outside the macro.

for field in struct {
    match field.name {
        "comments" => constraints.push(Constraint::Min(7)).
        "description" => constraints.push(Constraint::Min(7)),
        _ => constraints.push(Constraint.Length(3)),

    }
}

The below would be generated by something that looks like generate_blocks!(struct)

for field in struct {
    let block = Block::default()
        .borders(Borders::ALL)
        .style(Style::default())
        .title(field.name);
    let paragraph = Paragraph::new(Text::styled(field.value), Style::default().fg(Color::White);
    frame.render_widget(paragraph, field.value);
}

And the below would be generated somehow? Ideally, I would be able to generate the match arms with a specifiable operation rather than having to have a separate macro definition for push(), pop(), etc. This is in the key_handling code and would be inserted into a large nested match statement.

// for insertions
match struct.field {
    "name" => struct.name.push(char);
    ...
    last_field => struct.last_field.push(char);
}

or

// for removals
match struct.field {
    "name" => _ = struct.name.pop(char);
    ...
    last_field => _ = struct.last_field.pop(char);
}

For the constraint generation and the block generation, I could derive associated functions/methods and somehow pass in and return out what I need, but that feels a bit awkward to call a method that expands into a bunch of code. A macro call is much clearer that code is being generated.

Obviously, I could handwrite all this stuff out, but that is tedious and I would rather make the computer do it for me. Also this runs the risk of me forgetting a field or adding a new field and not implementing the rest of the functionality.

Please let me know if you need any more detail.

(complete code found here)

your example looks like the style commonly found in languages where runtime type information (a.k.a. reflection) is heavily used.

I know nothing about the ratatui crate, but is it possible to write in a style with direct access to the data in this UI kit? since you are generating the code anyway, you don't need to "loop" through some meta data about the "field", something like:

#[ui::component]
struct Recipe {
    #[ui::minmax(1000, 9999)]
    pub id: u64,
    #[ui::max_len(32)]
    pub name: String,
    pub description: Option<String>,
}

the generated code could look like:

fn render(&mut self, ui: UiContext) {
    ui.row(|ui| {
        ui.label("id");
        ui.slider(&mut self.id);
    });
    ui.row(|ui| {
        ui.label("name");
        ui.text_edit(&mut self.name);
    });
    ui.row(|ui| {
        ui.label("description");
        ui.multiline(&mut self.description);
    });
}

this is how I would imagine to do data binding in a "immediate rendering mode" style ui (actually it's how I do this kind of things in egui). it's possible that the ratatui library requires a complete different approach.

my point is, instead of generating metadata, it's better (and simpler) to access the data directly.

for example, don't generate code like this:

// metadata for a field
struct Field {
    name: &'static str,
    value: Value,
    constraint: Constraint,
}
enum Value {
    String(String),
    Integer(i64),
    Boolean(bool)
}

let mut fields = recipe.convert_to_field();
for field in fields {
    use_field_name(field.name);
    use_field_value(&mut field.value);
    use_field_constraint(&mut field.constraint);
}
recipe.update_fields(fields);
}

instead, you can "unroll" the loop and direct access the data:

use_field_name("id");
use_field_value(&mut recipe.id);
use_field_constraint(Constraint::minmax(1000, 9999));

use_field_name("name");
use_field_value(&mut recipe.name);
use_field_constraint(Constraint::max_len(32));

use_field_name("description");
use_field_value(&mut recipe.description);
use_field_constraint(Constraint::default());
3 Likes

This sounds like a fun problem to solve. I think you might find that a derive macro could be the right approach to this even though you don't yet have a trait in mind. Your data types could each implement Widget which would be a good place for the for field in struct parts that render widgets to end up rather than calling Frame::render_widget(), and you could just as easily define some traits for interactivity on your types (e.g. trait HandleKey). You might add some per field attributes to select the type of widget that will be used to render, add layout constraints and handle skipping fields etc.

I think this is pretty compatible with what @nerditation is suggesting above.

Right now a lot of your logic seems fairly horizontally sliced (see my rant about this at Is `impl Widget` introduced in tutorials too early? · Issue #519 · ratatui-org/ratatui-website · GitHub for a bit more info). I think this is something that some of the Ratatui docs tend to (inadvertently) push people into. An approach that organizes your code around the relevant concepts (recipe, step, equipment, ...) might lead to code that is easier to understand / build / maintain.

A couple of quick tips:

  • Block::bordered() is a shortcut to Block::default().borders(Borders::ALL)
  • All the style fields support Into<Style>, so you can just write e.g. Paragraph::new(field.value).style(Color::White)

Although it seems awkward, I think you might find handwriting the code first and then converting it into a macro as a second step will help you get to a result faster (at least that has in my limited experience writing macros).

I do have a long-term goal to have something like this built out a bit more for ratatui (or in ratatui-widgets), so it would be great to see where you get to in this (come say hi on the discord).

An example of a derive macro that does some of this (using println for the output, but I'm sure you can do the interesting parts).

pub trait Widget {
    fn render(&self);
}

use proc_macro::TokenStream;
use quote::quote;
use syn::{self, parse_macro_input, Data, DeriveInput};

#[proc_macro_derive(Widget)]
pub fn derive_widget(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = input.ident;
    match input.data {
        Data::Struct(data) => {
            // get the length of the longest field name so we can align the output
            let max_label_length = data
                .fields
                .iter()
                .map(|field| field.ident.as_ref().unwrap().to_string().len())
                .max()
                .unwrap_or(0);
            let fields = data.fields.iter().map(|field| {
                let name = &field.ident;
                // obviously replace this with actual rendering logic
                quote! {
                    println!(
                        "{:>label_length$}: {}",
                        stringify!(#name),
                        self.#name,
                        label_length = #max_label_length);
                }
            });
            let expanded = quote! {
                impl Widget for #name {
                    fn render(&self) {
                        #(#fields)*
                    }
                }
            };
            TokenStream::from(expanded)
        }
        _ => panic!("Widget derive only works on structs"),
    }
}

Then:

#[derive(Widget)]
pub struct Recipe {
    pub name: String,
    pub description: String,
}

pub fn main() {
    let recipe = Recipe {
        name: "Pancakes".to_string(),
        description: "Delicious pancakes".to_string(),
    };

    recipe.render();
}

Renders:

       name: Pancakes
description: Delicious pancakes

As to why you're having problems with the function approach - function macros replace one token stream with another - which seems to me they are probably a bad fit for the problem as you don't want to repeat the struct's contents multiple times.

See GitHub - joshka/spike-widget-macro for a working example for implementing Ratatui Widget.

1 Like

In my experience the duplication for UI code is ok.

If you start generating code usually your generator will break at the first 'special case'. And you end up with 25 attributes to control the generator.

And yes, I've been writing a ratatui application too in the last weeks. I've put the parts them might fit for libarification on github

2 Likes

Thank you joshka! I have been mainly following along with the Ratatui examples and never really looked in detail at the Widget trait. I will take a look at both those examples.

With the impl Widget approach, how do you wind up doing the final rendering to your chosen layout section / Rect? I obviously haven't read through your other links yet. My code is heavily based on the simple example in the Ratatui templates repo so will have to investigate to see how to modify things for that.

For the key_handling portion, I am still unclear how to determine which field is selected so i can direct the key input to it. Looking at the JSON Editor example, the App struct has a separate field for the key and value inputs, which are then written to the pairs HashMap when the key/value pair is saved. I obviously can't do that for every single field on all of my structs, so I was implementing a similar pattern where i have an edit_recipe: Option<Recipe> field, and then modified that struct and its sub components directly.

Thanks again for the replies, and help. I will definitely join the discord.

Thank you. I will investigate this. Not sure exactly how compatible it is with ratatui but as per joshka's reply, I have some more reading to do. Probably a hybrid approach would work.

I'm in agreement with this idea generally. One of the problems with code-gen is that it's an abstraction that forces you to think about code that doesn't exist, which is much harder than thinking about code that you can see directly. In general, unless done really well I'd also choose to avoid it for most things.

Using Proc / derive macros at an application level is definitely in the realm of "now you have two problems" type code. The macro I wrote as a PoC would be pretty difficult to maintain. I'm not sure I'd generally choose it over simpler choices. One of which would probably just be having a declarative macro call for each field. E.g. something like:

macro_rules! text_field { ... }

impl Recipe {
    text_field!(name);
    text_field!(description);
...
}

impl Widget for Recipe {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let layout = ...
        render_name(name_area, buf);
        render_descriptino(description_area, buf);
        ...
    }
}

Or even here you might go as simple as a normal function:

fn render_text_field(name: &str, value: &str, area: Rect, buf: &mut Buffer) {
    ...
}

That's pretty neat overall! Looks like you're building up a pretty good framework that would be useful to writing Ratatui apps.

In the example, I chose to avoid this complication and just incremented the y value of the area for each function, but in a real implementation of this, you'd probably attach an attribute (say layout) to each field, and use that to generate a layout using the normal ratatui approach. Like @ths mentions though, this sort of thing breaks as soon as you want to do something special for one of the elements.

That said, you can use cargo expand to eject your macro into code and just deal with the duplication, or you can write your macro in a way that it doesn't generate code that is marked with skip attributes so you can fill in your own version of things etc.

You're going to have to have some sort of focus tracking fields that makes the Recipe know which field is the active field. Whether this is via a trait implementation, a contained struct, or just fields with known names that are supported by your macro is up to you. If I was designing this, I'd probably have a second macro for handling this, but you might make it part of the derive Widget macro too.

I don't want to discourage you from the macro approach, as it will solve your problem, but if it were me in a simile situation, I'd probably go for something simpler up front when building an app, and save the macro approach for building a library / framework to handle building many apps.

3 Likes

As an update to this, I wound up writing one hell of a proc macro for this, with attributes etc. Learned a lot and now have a partially melted brain. Here it is, in all its horrible glory: https://github.com/sww1235/CookBookRS/blob/main/cookbook_macros/src/lib.rs

There seems to be a few crates trying to do this in various ways:

And I suspect you could abuse serde for this too (DeserializeSeed with a widget?)

More attempts the better though!

mainly just backsolving my hand implementation into macro form. Trying to be general within my crate and error when a field isn't being displayed, but not a totally general any widget any gui type macro.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.