Structuring code in a multi-page Crux app
Sean Borg February 19, 2025 [Blog] #RustWelcome to how I structure a multi-page application using the Elm architecture in the Crux framework. Writing this because I found a lot of single page app examples but haven't come across how people handle multiple pages.
All code examples come from my UpLine app
(specifically from the blog-post branch)
The first 4 things is how I structure the core (Rust), 5 is then the shell (Android) side. (p.s. I don't explain Crux at all, I am assuming your familiar with it. I would recommend the design overview as a quick way to understand this blog)
- Starting out with the data types I structure The Model
- Then continuing with the types I structure the Events
- Finally jumping into The Code and show how the above heavily influence the structure
- A quick look at how I organised the UiModel
- Lastly how the UiModel influences the Android code structure
The model
The internal data struct
Top level Model
#[derive(Default, Clone, Debug)]
pub struct Model {
pub screen: Screens,
pub user_messages: UserMessages,
pub example_data: ExampleData,
}
Above we have the Screens type, which hides all the magic, then anything
I want persistent across screens I need to also keep at this level so in
my case user_messages and example_data we keep out of Screens
Model.screens
The magic... A massive enum :D
// order is alphabetical to be in line with file browser
#[derive(Clone, Debug)]
pub enum Screens {
InitialScreen(InitialScreenData),
InputFieldsTagsScreen(InputFieldsAndTagsScreenData),
PostWriteReviewScreen(PostWriteReviewScreenData),
SelectOrgBucketMeasurementScreen(Box<SelectOrgBucketMeasurementScreenData>),
SettingPreviousConnectionsScreen(SettingsPreviousConnectionsActionsScreenData),
SettingsScreen,
}
With Rust allowing enum variants to hold internal data I make a variation per
screen appended with Screen and create a new data type per screen appended
with ScreenData, the naming scheme was helpful for myself to keep track of
what I have where.
Very simply clippy or the compiler suggested it once and I went with it, at some point I should review and maybe convert others. The reason was about the enum data types being wildly different sizes.
Moving screens
Originally thought moving screens would be problematic/annoying but using
Rust's strong typing we can get some nice wins from this. First is we can
take unvalidated user input held as option<String> and validate
it converting it to just a String or another strong type, simplifying
logic on later screens. We can see this between my first 2 screens
initial>SelectOrgBucketMeasurement.

Secondly we can leverage TryFrom quite nicely so we can do
~ let screen2: Screen2data = screen1data.tryinto()
allowing us to easily move to screen2 with the Err giving us a nice
message to relay back to the user
Events
Top level Events
Similar to Model with Event we can associate data with the enums,
this time though we are just wrapping up more enums as sub events
per screen, so...
// order is alphabetical to be in line with file browser
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum Event {
EvInitialScreen(ScEvInitial),
EvInputFieldsAndTagsEventsScreen(ScEvInputFieldsAndTags),
EvPostWriteReviewScreen(ScEvPostWriteReview),
EvSettingsScreen(ScEvSettings),
EvSelectOrgBucketMeasurementScreen(ScEvSelectOrgBucketMeasurement),
EvSettingsPreviousConnectionsScreen(ScEvSettingPreviousConnections),
EvGlobal(GlEvGlobal),
}
As the Event type crosses the FFI boundary I found it much nicer for type
completion in Kotlin to prepend the Ev for event variation and ScEv for
the internal data on each event variation.
Screen event
Internally the screen events
// order is alphabetical to be in line with file browser
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum ScEvInitial {
AppendPreviousConnection(PreviousConnection),
#[serde(skip)]
AppendPreviousConnectionResult(PersistentStorageOutput),
InfluxBucketsConnectionTest,
#[serde(skip)]
InfluxBucketsConnectionTestResponse(crux_http::Result<crux_http::Response<BucketsResult>>),
LoadPreviousConnections,
#[serde(skip)]
LoadPreviousConnectionsResult(PersistentStorageOutput),
QuickActionsLoad,
#[serde(skip)]
QuickActionsLoadResult(PersistentStorageOutput),
QuickActionsUserSelected(UiStringIndex),
LoadSettingsScreen,
SetApiToken(String),
SetBaseUrl(String),
UserSelectedPreviousConnection(UiStringIndex),
}
Above is all the events I have on the initial screen, this does make
calling an event in Android quite a long as we need to specify screen then event
e.g.
core.update(
Event.EvInitialScreen(
ScEvInitial.LoadPreviousConnections()
)
)
but I found I can make a simple function to reduce that
fun screenEvent(core: Core, coroutineScope: CoroutineScope, event: ScEvInitial) {
coroutineScope.launch {
core.update(
Event.EvInitialScreen(
event
)
)
}
}
// now calls look like
screenEvent(core, coroutineScope, LoadPreviousConnections())
Global Events
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum GlEvGlobal {
UpdateTutorial(u8),
BackButtonClicked,
}
Originally I had global events in the top level but figured it was just nicer
to wrap them up in another enum so they have a nice place to grow. 🌱
The Code
app.rs (Rust entry point)
First the Event enum and how that influences the code structure.

With the above picture we can see why I ordered things alphabetically, because
for each ScEv<name> I have made a folder screens/<name>/mod.rs. Keeping it
alphabetical helps with easily jumping between the enum folder and vice versa.
Top level update function
Now all my top level Events are really a group of screen events my top level
update function, is just passing the event onto a function in the mod.rs of
the corresponding folder e.g. event of type EvInitialScreen will call a
function in screens/initial/mod.rs, I took the naming scheme of initial_screen_events
fn update(&self, event: Self::Event, model: &mut Self::Model, _caps: &Self::Capabilities, )
-> Command<Effect, Event> {
// ...
match event {
InitialScreenEvents(internal_event) => initial_screen_events(internal_event, model),
// ...
}
}
Full update function at time of writing
The actuality of the function has a bit of global setup, so for me setting up
android logging and some messing with
the user_messages. But after all that we have the same match statement and
keeping the alphabetical order to help find
things, so as long as we don't have a semicolon at the end we can quite quickly
verify that all the return commands are
actually coming from screen/<name>/mod.rs
fn update(&self, event: Self::Event, model: &mut Self::Model, _caps: &Self::Capabilities,
) -> Command<Effect, Event> {
START.call_once(|| {
android_logger::init_once(
Config::default()
.with_max_level(log::LevelFilter::Warn)
.with_tag("FL:C"),
);
});
trace!("Clear all existing messages");
model.user_messages.remove_all_messages();
trace!("Run event");
trace!("model:{:?}", model.screen);
trace!("event:{:?}", event);
match event {
InitialScreenEvents(internal_event) => initial_screen_events(internal_event, model),
SelectOrgBucketMeasurementScreenEvents(internal_event) => {
select_org_bucket_measurement_screen_events(internal_event, model)
}
InputFieldsAndTagsEventsScreenEvents(internal_event) => {
input_fields_and_tags_events(internal_event, model)
}
SettingsScreenEvents(internal_event) => settings_events(internal_event, model),
SettingsPreviousConnectionsScreenEvents(internal_event) => {
setting_previous_connections_events(internal_event, model)
}
PostWriteReviewScreenEvents(event) => post_write_review_screen_events(event, model),
GlobalEvent(event) => global_events(event, model),
}
}
Screen mod.rs
Events / update function
We shall get to why mod.rs has 400 lines before we get to the only function in
the file, but here is one of ourscreen/<name>/mod.rs::<name>_screen_events
functions.

First now we are one level down we can get the screen data out of the model,
sadly this can't be a thing we can assert at build time but in general the
folder structure makes this easy to maintain. This allows us to pass sreenData
to our functions reducing scope the functions can effect and saving dealing
with repeatedly asserting the screen model.
In general, we have repeated the same trick we had in app.rs now in
screen/<name>/mod.rs this time all our events correspond to a file. The enum
variant / file name / function
all have the same name (within naming conventions) e.g. variant
BackButtonPressed() /
file name back_button_pressed.rs / function fn back_button_pressed() {}.
Again keeping alphabetical order to make things easier to find.
My full `function` at time of writing
pub fn input_fields_and_tags_events(
initial_screen_event: InputFieldsAndTagsScreenEventsData,
model: &mut Model,
) -> Command<Effect, Event> {
trace!("input_fields_and_tags screen event: Validating model");
let Screens::InputFieldsTagsScreen(ref mut screen_data) = model.screen else {
error!("Screen was in an unexpected state {:?}", model.screen);
model.user_messages.error_report_to_developer(N065_USED);
return crux_core::render::render();
};
trace!("Running input fields and tags event");
match initial_screen_event {
InputFieldsAndTagsScreenEventsData::BackButtonPressed => {
back_button_pressed(&mut model.screen, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::FieldExistingAdd(field) => {
field_existing_add(screen_data, field, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::FieldExistingRemove(field) => {
field_existing_remove(screen_data, field)
}
InputFieldsAndTagsScreenEventsData::FieldExistingValueSet(key, value) => {
field_existing_value_set(key, value, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::FluxGetMeasurementsExistingFields => {
flux_get_measurements_existing_fields(
screen_data,
&mut model.user_messages,
&model.example_data,
)
}
InputFieldsAndTagsScreenEventsData::FluxGetMeasurementsExistingFieldsResponse(response) => {
flux_get_measurements_existing_fields_response(
screen_data,
response,
&mut model.user_messages,
&model.example_data,
)
}
InputFieldsAndTagsScreenEventsData::FluxGetMeasurementsExistingTags => {
flux_get_measurements_existing_tags(
screen_data,
&mut model.user_messages,
&model.example_data,
)
}
InputFieldsAndTagsScreenEventsData::FluxGetMeasurementsExistingTagsResponse(response) => {
flux_get_measurements_existing_tags_response(
screen_data,
response,
&mut model.user_messages,
&model.example_data,
)
}
InputFieldsAndTagsScreenEventsData::FluxGetTagsExistingValues => Command::done(),
InputFieldsAndTagsScreenEventsData::FluxGetTagsExistingValuesResponse(response, tag) => {
flux_get_tags_existing_values_response(
screen_data,
response,
&mut model.user_messages,
tag,
)
}
InputFieldsAndTagsScreenEventsData::InfluxWrite => influx_write(
&mut model.screen,
&mut model.user_messages,
&model.example_data,
),
InputFieldsAndTagsScreenEventsData::InfluxWriteResponse(response) => {
influx_write_response(response, model)
}
InputFieldsAndTagsScreenEventsData::LineProtocolSubmitLine => Command::done(),
InputFieldsAndTagsScreenEventsData::TagExistingAdd(tag) => tag_existing_add(
screen_data,
&mut model.user_messages,
tag,
&model.example_data,
),
InputFieldsAndTagsScreenEventsData::TagExistingRemove(tag) => {
tag_existing_remove(screen_data, tag)
}
InputFieldsAndTagsScreenEventsData::TagExistingValueAcceptModeChange(key) => {
tag_existing_value_accept_mode_change(key, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::TagExistingValueDismissModeChange(key) => {
tag_existing_value_dismiss_mode_change(key, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::TagExistingValueSetIndex(key, value) => {
tag_existing_value_set_index(key, value, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::TagExistingValueSetUser(key, value) => {
tag_existing_value_set_user(key, value, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::TagNewAdd() => {
tag_new_add(screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::TagNewKaySet(string_index, key) => {
tag_new_key_set(string_index, key, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::TagNewRemove(string_index) => {
tag_new_remove(string_index, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::TagNewValueSet(string_index, value) => {
tag_new_value_set(string_index, value, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::TimeSet(time) => time_set(time, screen_data),
InputFieldsAndTagsScreenEventsData::FieldNewAdd => {
field_new_add(screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::FieldNewRemove(string_index) => {
field_new_remove(string_index, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::FieldNewValueSet(string_index, new_value) => {
field_new_value_set(
new_value,
string_index,
screen_data,
&mut model.user_messages,
)
}
InputFieldsAndTagsScreenEventsData::FieldNewKeySet(string_index, new_key) => {
field_new_key_set(new_key, string_index, screen_data, &mut model.user_messages)
}
InputFieldsAndTagsScreenEventsData::FieldNewChangeType(string_index, new_type) => {
field_new_change_type(
new_type,
string_index,
screen_data,
&mut model.user_messages,
)
}
}
}
We can see, although it gets bigger so long as im not terminating the match with a semicolon Rust will guarantee we are passing the output of each match arm as our output to the function
Screen Events & Model
After watching both the Events enum and Model struct grow in their respective
top level files I decided to pull them into the screen/<name>/mod.rs as well.
This made a lot of sense as mod.rs is the entry point for the file it is now
also where the Events and Data is defined, keeping the majority of my work on a
screen in 1 folder. Downside mod.rs gets very big, my order was:
- Model
- Events
- Update function
which is how the update function ends up 400 lines down :D


In the images above we see the alphabetical ordering again, and the tryFrom mentioned earlier on.
Full file
Function files
Tiny subset of the function files to see how everything works.
Load quick actions
In the bellow we have an example of a very small function but this is great to show the testing of the commands
pub fn load_quick_actions() -> Command<Effect, Event> {
trace!("load_quick_actions called");
persistent_storage::load(QUICK_ACTIONS_FILENAME.to_owned()).then_send(construct_response_event)
}
fn construct_response_event(body: PersistentStorageOutput) -> Event {
EvInitialScreen(ScEvInitial::QuickActionsLoadResult(body))
}
mod tests {
use super::*;
use crate::screens_common::test_common::test_shell;
#[test]
fn test_load_quick_actions() {
let mut commands = load_quick_actions();
// confirm the correct events are called
let effects = commands.effects().collect();
insta::assert_debug_snapshot!(
effects, @r#"[
PersistentStorage(
Request(
Load("quick_actions",),
),
),
]"#
);
test_shell(effects);
let events: Vec<Event> = commands.events().collect();
insta::assert_debug_snapshot!(
events, @r#" [
EvInitialScreen(
QuickActionsLoadResult(
FileData("Just a fake test",),
),
),
]"#
);
}
}
since there is no branching only one test is needed and really all im doing is
confirming I called the right construct_response_event function but as I know
this function is being called from both the match in app.rs then screen/<name>/mod.rs
I can be extremely confident that this function with very little scope for
mutability is the side effect of the loadQuickActions event and that it is now
100% tested.
Load quick actions result
Obviously these functions don't stay so small, here's a more average one
pub fn load_quick_actions_result(
screen_data: &mut InitialScreenData,
user_messages: &mut UserMessages,
response: PersistentStorageOutput,
) -> Command<Effect, Event> {
trace!("load_quick_actions_results called");
match response {
PersistentStorageOutput::FileData(data) => {
debug!("loaded data: {data}");
let fp = BufReader::new(data.as_bytes());
let reader = JsonLinesReader::new(fp);
let items = reader
.read_all::<QuickAction>()
.collect::<std::io::Result<Vec<_>>>();
match items {
Ok(value) => {
model.quick_actions = RemoteResource::Loaded(MultiChoice {
options: value,
selected: None,
});
crux_core::render::render()
}
Err(error) => {
error!("Failed to get previous connections, error: {:?}", error);
error!("data was {}", data);
user_messages.add_message(
format!("Failed to get previous connections, error: {:?}", error),
Level::Error,
);
crux_core::render::render()
}
}
}
PersistentStorageOutput::SaveOk
| PersistentStorageOutput::AppendOk
| PersistentStorageOutput::Error => todo!(),
PersistentStorageOutput::LoadErrorFileDoesntExist => {
warn!("Quick actions file doesnt exist!");
screen_data.quick_actions = RemoteResource::NonExistent;
crux_core::render::render()
}
}
}
Once we add the tests for this the file gets quite huge!
use crate::capabilities::persistent_storage::PersistentStorageOutput;
use crate::model::UserMessages;
use crate::screens::initial::{InitialScreenData, QuickAction};
use crate::screens::input_fields_and_tags::RemoteResource;
use crate::screens_common::model::multi_choice::MultiChoice;
use crate::{Effect, Event};
use crux_core::Command;
use log::{debug, error, trace, warn, Level};
use serde_jsonlines::JsonLinesReader;
use std::io::BufReader;
pub fn load_quick_actions_result(
model: &mut InitialScreenData,
user_messages: &mut UserMessages,
response: PersistentStorageOutput,
) -> Command<Effect, Event> {
trace!("load_quick_actions_results called");
match response {
PersistentStorageOutput::FileData(data) => {
debug!("loaded data: {data}");
let fp = BufReader::new(data.as_bytes());
let reader = JsonLinesReader::new(fp);
let items = reader
.read_all::<QuickAction>()
.collect::<std::io::Result<Vec<_>>>();
match items {
Ok(value) => {
model.quick_actions = RemoteResource::Loaded(MultiChoice {
options: value,
selected: None,
});
crux_core::render::render()
}
Err(error) => {
error!("Failed to get previous connections, error: {:?}", error);
error!("data was {}", data);
user_messages.add_message(
format!("Failed to get previous connections, error: {:?}", error),
Level::Error,
);
crux_core::render::render()
}
}
}
PersistentStorageOutput::SaveOk
| PersistentStorageOutput::AppendOk
| PersistentStorageOutput::Error => todo!(),
PersistentStorageOutput::LoadErrorFileDoesntExist => {
warn!("Quick actions file doesnt exist!");
model.quick_actions = RemoteResource::NonExistent;
crux_core::render::render()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::screens::initial::InitialScreenData;
use crate::screens_common::test_common::test_shell;
#[test]
fn test_load_previous_connections_result_empty() {
let mut screen_data = InitialScreenData {
..Default::default()
};
let mut user_messages = UserMessages::default();
let response = PersistentStorageOutput::FileData("".to_owned());
let mut commands =
load_quick_actions_result(&mut screen_data, &mut user_messages, response);
// =========================================================================================
// check that the view has been updated correctly
// =========================================================================================
// todo confirm if I want an empty list or a non exist
insta::assert_debug_snapshot!(screen_data, @r#"
InitialScreenData {
api_token: None,
base_url: None,
quick_actions: Loaded(
MultiChoice {
options: [],
selected: None,
},
),
previous_connections: NotLoaded(
LoadingResource {
retry_count: 0,
failure_reasons: [],
current_state: Initialised,
},
),
failed_to_contact_influx: false,
}
"#);
insta::assert_debug_snapshot!(user_messages, @r#"
UserMessages {
messages: [],
current_id: 0,
}
"#);
// =========================================================================================
// confirm the correct events are called
// =========================================================================================
let effects = commands.effects().collect();
insta::assert_debug_snapshot!(
effects, @r#"
[
Render(
Request(
RenderOperation,
),
),
]
"#);
test_shell(effects);
let events: Vec<Event> = commands.events().collect();
insta::assert_debug_snapshot!(
events, @"[]");
}
#[test]
fn test_load_previous_connections_result_file_not_exist() {
let mut screen_data = InitialScreenData {
..Default::default()
};
let mut user_messages = UserMessages::default();
let response = PersistentStorageOutput::LoadErrorFileDoesntExist;
let mut commands =
load_quick_actions_result(&mut screen_data, &mut user_messages, response);
// =========================================================================================
// check that the view has been updated correctly
// =========================================================================================
insta::assert_debug_snapshot!(screen_data, @r#"
InitialScreenData {
api_token: None,
base_url: None,
quick_actions: NonExistent,
previous_connections: NotLoaded(
LoadingResource {
retry_count: 0,
failure_reasons: [],
current_state: Initialised,
},
),
failed_to_contact_influx: false,
}
"#);
insta::assert_debug_snapshot!(user_messages, @r#"
UserMessages {
messages: [],
current_id: 0,
}
"#);
// =========================================================================================
// confirm the correct events are called
// =========================================================================================
let effects = commands.effects().collect();
insta::assert_debug_snapshot!(
effects, @r#"
[
Render(
Request(
RenderOperation,
),
),
]
"#);
test_shell(effects);
let events: Vec<Event> = commands.events().collect();
insta::assert_debug_snapshot!(
events, @"[]");
}
#[test]
fn test_load_previous_connections_result() {
let mut screen_data = InitialScreenData {
..Default::default()
};
let mut user_messages = UserMessages::default();
let response = PersistentStorageOutput::FileData(
r#"{"name":"test","api_token":"vzO0_9-u3Y0Q-FyO88bIVK8g3beRkvk00hDppgMGiiNVmk1wm0Cmek_QT2hTxcqdUL607FSMtgephwprZPIl7A==","base_url":"http://100.108.38.15:8086/","org":{"name":"test","id":"3aeb5581111a8d5a"},"bucket":"personal","measurement":"test_crux","tags":{"new_tag2":"hey2"},"fields":{"akey":{"Float":{"inner":""}}}}"#
.to_owned(),
);
let mut commands =
load_quick_actions_result(&mut screen_data, &mut user_messages, response);
// =========================================================================================
// check that the view has been updated correctly
// =========================================================================================
insta::assert_debug_snapshot!(screen_data, @r#"
InitialScreenData {
api_token: None,
base_url: None,
quick_actions: Loaded(
MultiChoice {
options: [
QuickAction {
name: "test",
api_token: "vzO0_9-u3Y0Q-FyO88bIVK8g3beRkvk00hDppgMGiiNVmk1wm0Cmek_QT2hTxcqdUL607FSMtgephwprZPIl7A==",
base_url: Url {
scheme: "http",
cannot_be_a_base: false,
username: "",
password: None,
host: Some(
Ipv4(
100.108.38.15,
),
),
port: Some(
8086,
),
path: "/",
query: None,
fragment: None,
},
org: Org {
name: "test",
id: "3aeb5581111a8d5a",
},
bucket: "personal",
measurement: "test_crux",
tags: {
"new_tag2": Some(
"hey2",
),
},
fields: {
"akey": Float(
ValidatedFieldF64 {
inner: "",
},
),
},
},
],
selected: None,
},
),
previous_connections: NotLoaded(
LoadingResource {
retry_count: 0,
failure_reasons: [],
current_state: Initialised,
},
),
failed_to_contact_influx: false,
}
"#);
insta::assert_debug_snapshot!(user_messages, @r#"
UserMessages {
messages: [],
current_id: 0,
}
"#);
// =========================================================================================
// confirm the correct events are called
// =========================================================================================
let effects = commands.effects().collect();
insta::assert_debug_snapshot!(
effects, @r#"
[
Render(
Request(
RenderOperation,
),
),
]
"#);
test_shell(effects);
let events: Vec<Event> = commands.events().collect();
insta::assert_debug_snapshot!(
events, @"[]");
}
}
Global events / Screen common
Organisation
Global events wise I found it nicer to group them like a screen at the moment I
group them in a global folder I am tempted to put them in screens/global.
Other than that it's the same structure.

screens_common is quite simply anything that I could generalise across screens
functions are top level and data types are under screens_common/model. Stuff
in here will be imported in multiple screen functions.
Global event propagation
A trick I found with some global events was that if the event changes depending
on what screen its on we can just make an event in the screens folder
so for the global back button in android

I implement back depending which screen im on, this will help as well for when I move to iOS because they don't have a global back button, so I will just call these events directly
UiModel (ViewModel)
Last core organisation I did was the UiModel, renamed it UiModel instead of ViewModel for fewer chars, and it made more sense to me.

Maybe this could belong in the screens/<name>/mod.rs but those files are big
enough and the UiModel crosses the FFi so made sense to keep it separate.
Organisation wise very similar setup to screens but now instead of folders I
have a file per screen.
90% of this is just from methods :D
Android
Organising jetpack compose
Lastly the Android (shell) side, we can keep a surprisingly similar layout

so instead of a match statement per screen we have a when statement working
in a very similar way, from there was can call a compose method in a separate
file so now every screen has a file under ui/<screen name>
Utilising preview
Then with the lovely @preview annotation we can just build out our UI's while
staying all within the one file, I suspect if these files grow much more I may
move to a folder structure similar to core.

Conclusion
In conclusion my folder structure in core (Rust) is
├── app.rs
├── capabilities
│  └── persistent_storage.rs
├── global
│  ├── mod.rs
│  ├── function1.rs
│  └── function2.rs
├── lib.rs
├── model.rs
├── model_ui
│  ├── mod.rs
│  ├── screens
│  │  ├── scren1.rs
│  │  └── screen2.rs
├── screens
│  ├── screen1
│  │  ├── mod.rs
│  │  ├── function1.rs
│  │  └── function2.rs
│  ├── screen2
│  │  ├── mod.rs
│  │  └── function1.rs
├── screens_common
│  ├── mod.rs
│  ├── common_function1.rs
│  ├── common_function2.rs
│  ├── model
│  │  ├── mod.rs
│  │  ├── common_type1.rs
│  │  └── common_type2.rs
└── shared.udl
- Naming schemes
- Events
Ev<screen name>for event variationScEv<Scren name>for the variation data- keeping it alphabetical for easy searching.
- Model Screens
<screen name>Screenfor variations<screen name>ScreenDatafor variation data
- Ui Model (realising I don't stick to this scheme but should update my code)
- Everything is prepended with
Uito keep an easy distinction for what goes over FFI and what doesn't Ui<screen name>Screenfor variationsUi<screen name>ScreenDatafor variation data
- Everything is prepended with
- Events
Pros
- The majority of my time is spent in just a single folder working on 1 screen
- good separation of functions for testing
- simple naming scheme to follow
Cons
- not 100% how this will work if I swap from multi screens to 1 large screen and multi panes