I have a nacent todo list app that is using Ratatui, but have run in to more of a Rust design question than a Ratatui one. I’m asking here because it has to do with how to store application state in an ergonomic way, which is a problem Ratatui apps need to solve in general.
My application state is stored in a struct:
pub(crate) struct State {
pub list: TodoList,
pub current_screen: Screen,
}
With the Screen
enum containing per-screen state:
pub(crate) enum Screen {
Main,
Edit(EditState),
}
And in this case, EditState
holds the stuff needed only when editing a todo item. Right now the only editable thing is the todo’s description, so this is straightforward goop that Ratatui wants to track across renderings:
pub(crate) struct EditState {
pub index: usize,
pub text_state: TextState<'static>,
}
Now for code that handles key presses:
pub(crate) fn update(
state: &mut ui_state::State,
key_event: crossterm::event::KeyEvent,
) -> Disposition {
match &mut state.current_screen {
ui_state::Screen::Main => {
let key_combination: crokey::KeyCombination = key_event.into();
update_main_screen(key_combination, state)
}
ui_state::Screen::Edit(edit_state) => {
let text_state = &mut edit_state.text_state;
assert!(text_state.is_focused());
text_state.handle_key_event(key_event);
match text_state.status() {
tui_prompts::Status::Pending => Disposition::Continue,
tui_prompts::Status::Aborted => {
// TODO: When aborting a new item, delete it.
state.current_screen = ui_state::Screen::Main;
Disposition::Continue
}
tui_prompts::Status::Done => {
let task = &mut state.list.tasks.tasks[edit_state.index];
task.title = text_state.value().into();
state.current_screen = ui_state::Screen::Main;
Disposition::Continue
}
}
}
}
}
This works, but I’d like to break this function up and handle the Edit
screen in its own function. Using Rust Analyzer’s function extraction feature I get:
pub(crate) fn update(
state: &mut ui_state::State,
key_event: crossterm::event::KeyEvent,
) -> Disposition {
match &mut state.current_screen {
ui_state::Screen::Main => {
let key_combination: crokey::KeyCombination = key_event.into();
update_main_screen(key_combination, state)
}
ui_state::Screen::Edit(edit_state) => update_edit_screen(edit_state, key_event, state),
}
}
Nice. But it doesn’t compile:
1 error[E0499]: cannot borrow `*state` as mutable more than once at a time
--> src/update.rs:29:89
|
24 | match &mut state.current_screen {
| ------------------------- first mutable borrow occurs here
...
29 | ui_state::Screen::Edit(edit_state) => update_edit_screen(edit_state, key_event, state),
| ------------------ ^^^^^ second mutable borrow occurs here
| |
| first borrow later used by call
For more information about this error, try `rustc --explain E0499`.
error: could not compile `sift` (bin "sift") due to 1 previous error
I understand the error. I’ve matched on &mut state.current_screen
, kept that borrow active (for lack of a better word) with the edit_state: &mut EditState
reference, and then I pass both edit_state
and state
to a function. This makes sense even as a code smell: I’ve extracted a reference to something within state
and I am passing both that and state
to a function.
Before I extracted this new, broken, function, the compiler could see more of the code and, I assume, figure out that the lifetimes worked out. So, I’m left wondering: if my design works out okay when events are handled within one function, but breaks down when I refactor into smaller, “simpler”, function calls, am I missing a way to design this that works well regardless?
The Question
I’m interested in general advice for structuring my application state to avoid this kind of thing in most cases. Are there well known patterns for structuring state in Ratatui apps that allow the event handling “mutation logic” to be easily refactored across functions?