Django-like Inspired Archicture for Applications

So for the last few weeks I’ve been experimenting with iced and git2-rs libraries and got to a point where I was in the need to introduce a pane_grid to one of the views.

That triggered a fairly amount of refactoring on the code I was not all that happy to deal with but made me start to think about how my application was organized.

Since most of my career was spent dealing with web applications using Django I decide to try creating a brand new simple application with the goal to discover how hard would it be to trying organize a project in the manner Django handle the MVC pattern.

All that resulted into the following project: prometheus/iced-micro-apps-architecture - Codeberg.org

The main objective to this “MVC” implementation is to make it easier to sync both the update and view phases of the main loop to a certain view/screen.

One of the first things I noticed when I started growing my application was the fact that a lot of operations (specially around the manipulation of the application state) was concentrate on the main Application impl. That lead me to this confusing workflow of alternate between 4 or more tabs just because I decide to change one thing on a “Screen A” to “Screen B” workflow.

To resolve that I was thing about concentrate little more code at a single module. That is achieve by making the Application::update behave in two modes:

  1. Routing
  2. State Manipulation

The first thing Application::update tries is to detect if we want switch views/screens. A lean, clean and objective code block:

    fn update(&mut self, message: Self::Message) -> iced::Command<Self::Message> {
        // At the main Application we only need to handle the routing/swap
        // between Screens/Scenes/Views
        match &message {
            Message::NavigateTo(url, states) => {
                match url.as_str() {
                    main_window::navigation::NAME => *self = App::MainWindow(states.clone()),
                    custom_app_1::navigation::NAME => *self = App::CustomApp1(states.clone()),
                    custom_app_2::navigation::NAME => *self = App::CustomApp2(states.clone()),
                    _ => *self = App::MainWindow(states.clone()),
                }
            },
            // On this block we only care about routing to the proper screen
            _ => (),
        }
        // ...
}

After that we will proceed to update the state of the Application BUT the actual changes are NOT responsibility of the Application. That will now be delegated to the views/screens itself.

        // ...
        // If we did not received a routing request we move on to check which
        // Screen/Scene/View is currently on focus (being used) and delegate
        // the Self::Message consummation to the "micro-application" (view)
        // itself
        let new_states;
        let cmd;
        match self {
            Self::MainWindow(states) => {
                (new_states, cmd) = main_window::update::update(states, message.clone());
                match new_states {
                    Some(s) => *self = Self::CustomApp1(s),
                    _ => (),
                }
            },
            Self::CustomApp1(states) => {
                (new_states, cmd) = custom_app_1::update::update(states, message.clone());
                match new_states {
                    Some(s) => *self = Self::CustomApp1(s),
                    _ => (),
                }
            },
            Self::CustomApp2(states) => {
                (new_states, cmd) = custom_app_2::update::update(states, message.clone());
                match new_states {
                    Some(s) => *self = Self::CustomApp1(s),
                    _ => (),
                }
            },
        }
        return cmd;

Notice that the “default” behavior here is to NOT change the “state” of the Application at all. We do that ONLY when the view/screens tells us to (return a brand new state instance).

Great so what about the messages we use to make changes for example on “Custom App 1” view? Well on the custom_app_1.rs module we would have something like below:

pub mod update {
    use crate::{globals::{Message, States}, screens::custom_app_1::msg::CustomApp1};

    pub fn update(states: &States, message: Message) -> (Option<States>, iced::Command<Message>){
        println!("processing updates through custom app 1");
        dbg!(&states.custom_app_1);
        match message {
            Message::CustomApp1(custom_app_1_msg) => {
                match custom_app_1_msg {
                    CustomApp1::AppendABC => {
                        let mut new_states = states.clone();
                        new_states.custom_app_1.coming_from = format!(
                            "{} {}",
                            states.custom_app_1.coming_from,
                            "abc"
                        );
                        return (
                            Some(new_states),
                            iced::Command::none(),
                        )
                    }
                }
            }
            // We don't care about other "screens" messages.
            _ => (),
        }
        (None, iced::Command::none())
    }
}

You’re probably wondering why we need to matches for the message. Well that’s needed because we don’t want to append values to the Message enum in a “global” manner. Once the Application “routes” the flow to “custom app 1” we should only care about messages related to that view/screen/app. To achieve that the custom_app_1.rs provides:

pub mod msg {
    #[derive(Debug, Clone)]
    pub enum CustomApp1 {
        AppendABC,
    }
}

and the “main” Application impl makes uses of enum CustomApp1 like this:


#[derive(Debug, Clone)]
pub enum Message {
    NavigateTo(String, States),
    CustomApp1(custom_app_1::msg::CustomApp1),
    CustomApp2(custom_app_2::msg::CustomApp2),
}

Just scroll up a little to check the fn update from custom_app_1.rs again :slight_smile:

(naming pattern still pending though)

At this point all that remains is the actual widget construction. That responsibility is also delegated from Application impl to the view/screen/app. Application does NOT need to know what or how to draw it just need to act was a middle man to pass the Elements along:

    // impl Application for App
    fn view(&self) -> iced::Element<Self::Message> {
        // Same logic from the update. We don't need to render anything here.
        // the screens will be handle what they want to show.
        match self {
            App::MainWindow(states) => {
                main_window::view::view(states)
            }
            App::CustomApp1(states) => {
                custom_app_1::view::view(states)
            },
            App::CustomApp2(states) => {
                custom_app_2::view::view(states)
            }
        }
    }

back again at the custom_app_1.rs:

pub mod view {
    use iced::widget::{button, text};
    use crate::globals;
    use crate::screens::main_window;

    pub fn view<'a>(states: &globals::States) -> iced::Element<'a, globals::Message> {
        iced::widget::column!(
            text(format!("Text: {}", states.custom_app_1.coming_from)),
            button("go back to main menu").on_press(
                globals::Message::NavigateTo(
                    main_window::navigation::NAME.to_string(),
                    states.clone(),
                )
            ),
            button("append abc to text").on_press(
                globals::Message::CustomApp1(
                    super::msg::CustomApp1::AppendABC
                )
            )
        ).into()
    }
}

Well… There’s a ton of rooms for improvement. I believe most of the boilerplate could be resolved with derive macros (which I do not dominate at this point :/) and the naming conventions could be a lot better.

Hope you guys have time to snoop around the repo and confirm that it’s pretty easy to navigate the project using this pattern.

Keep Grinding :saluting_face: