Introduction
I wrote my first ratatui application, which was monitoring changed files, recalculated the universe and showed potential errors. That worked fine, and I got more ambitious.
Write a real UI for the cmd-line application I need for my day to day work. And that quickly stalled, with quite a few bits and pieces missing.
So I started this rat-salsa thing, to add a bit of spicy sauce to my ratatouille.
Reinvent the wheel
Widgets
The StatefulWidget
trait works good enough for building
widgets, it's well known and my own ideas where not sufficiently
better so I kept that one.
All the widgets work just as plain StatefulWidgets. This effort lead to the rat-widget crate.
Or see the introduction in widget chapter.
Application code
For the application code StatefulWidget
is clearly missing, but
I kept the split-widget concept and there are two traits
-
- Keeps the structure of StatefulWidget, just adds a RenderContext.
-
AppState The state is the persistent half of every widget, so this one gets all the event-handling.
There are functions for application life-cycle and and event() that is called for every application event.
- I currently have a driver for crossterm events, but this can easily be replaced with something else.
run_tui
run_tui implements the event-loop and drives the application.
- Polls all event-sources and ensures fairness for all events.
- Renders on demand.
- Maintains the background worker threads.
- Maintains the timers.
- Distributes application events.
- Initializes the terminal and ensure clean shutdown even when panics occur.
All of this is orchestrated with the Control enum.
minimal
A walkthrough for examples/minimal.rs, a starting point for a new application.
main
fn main() -> Result<(), Error> { setup_logging()?; let config = MinimalConfig::default(); let theme = DarkTheme::new("Imperial".into(), IMPERIAL); let mut global = GlobalState::new(config, theme); let app = Scenery; let mut state = SceneryState::default(); run_tui( app, &mut global, &mut state, RunConfig::default()? .poll(PollCrossterm) .poll(PollTimers) .poll(PollTasks), )?; Ok(()) }
run_tui is fed with
-
app: This is just the unit-struct Scenery. It provides the scenery for the application, adds a status bar, displays error messages, and forwards the real application Minimal.
-
global: whatever global state is necessary. This global state is useable across all app-widgets. Otherwise, the app-widgets only see their own state.
-
state: the state-struct SceneryState.
-
RunConfig: configures the event-loop
-
If you need some special terminal init/shutdown commands, implement the rat-salsa::Terminal trait and set it here.
-
Set the number of worker threads.
-
Add the event-sources. Implement the PollEvents trait.
See examples/life.rs for an example.
Here we go with default drivers PollCrossterm for crossterm, PollTimers for timers, PollTasks for the results from background tasks.
-
The rest is not very exciting. It defines a config-struct which is just empty, loads a default theme for the application and makes both accessible via the global state.
mod global
Defines the global state...
#![allow(unused)] fn main() { #[derive(Debug)] pub struct GlobalState { pub cfg: MinimalConfig, pub theme: DarkTheme, pub status: StatusLineState, pub error_dlg: MsgDialogState, } }
mod config
Defines the config...
#![allow(unused)] fn main() { pub struct MinimalConfig {} }
mod event
This defines the event type throughout the application.
#[derive(Debug)]
pub enum MinimalEvent {
Timer(TimeOut),
Event(crossterm::event::Event),
Message(String),
}
The trick here is that every PollXXX that you add requires that you provide a conversion from its event-type to your application event-type.
impl From<TimeOut> for MinimalEvent {
fn from(value: TimeOut) -> Self {
Self::Timer(value)
}
}
impl From<crossterm::event::Event> for MinimalEvent {
fn from(value: Event) -> Self {
Self::Event(value)
}
}
But otherwise you are free to add more.
Specifically you can add any events you want to send between the different parts of your application. There's a need for that. If you split the application into multiple AppWidget/AppState widgets there is no easy way to communicate between parts.
Other approaches set up channels to do this, but rat-salsa just uses the main event-queue to distribute such messages.
mod scenery
#![allow(unused)] fn main() { #[derive(Debug)] pub struct Scenery; #[derive(Debug, Default)] pub struct SceneryState { pub minimal: MinimalState, } }
Defines a unit struct for the scenery and a struct for any state. Here it holds the state for the actual application.
AppWidget
#![allow(unused)] fn main() { impl AppWidget<GlobalState, MinimalEvent, Error> for Scenery { type State = SceneryState; fn render( &self, area: Rect, buf: &mut Buffer, state: &mut Self::State, ctx: &mut RenderContext<'_>, ) -> Result<(), Error> { let t0 = SystemTime::now(); let layout = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).split(area); Minimal.render(area, buf, &mut state.minimal, ctx)?; if ctx.g.error_dlg.active() { let err = MsgDialog::new().styles(ctx.g.theme.msg_dialog_style()); err.render(layout[0], buf, &mut ctx.g.error_dlg); } let el = t0.elapsed().unwrap_or(Duration::from_nanos(0)); ctx.g.status.status(1, format!("R {:.0?}", el).to_string()); let status_layout = Layout::horizontal([Constraint::Fill(61), Constraint::Fill(39)]).split(layout[1]); let status = StatusLine::new() .layout([ Constraint::Fill(1), Constraint::Length(8), Constraint::Length(8), ]) .styles(ctx.g.theme.statusline_style()); status.render(status_layout[1], buf, &mut ctx.g.status); Ok(()) } } }
Implement the AppWidget trait. This forwards rendering to Minimal, and then renders a MsgDialog if needed for error messages, and the status line. The default displays some timings taken for rendering too.
AppState
#![allow(unused)] fn main() { impl AppState<GlobalState, MinimalEvent, Error> for SceneryState { }
AppState has three type parameters that occur everywhere. I couldn't cut back that number any further ...
#![allow(unused)] fn main() { fn init(&mut self, ctx: &mut AppContext<'_>) -> Result<(), Error> { ctx.focus = Some(FocusBuilder::for_container(&self.minimal)); self.minimal.init(ctx)?; Ok(()) } }
init is the first event for every application.
it sets up the initial Focus for the application and forwards to MinimalState.
#![allow(unused)] fn main() { fn event( &mut self, event: &MinimalEvent, ctx: &mut rat_salsa::AppContext<'_, GlobalState, MinimalEvent, Error>, ) -> Result<Control<MinimalEvent>, Error> { let t0 = SystemTime::now(); let mut r = match event { MinimalEvent::Event(event) => { let mut r = match &event { ct_event!(resized) => Control::Changed, ct_event!(key press CONTROL-'q') => Control::Quit, _ => Control::Continue, }; r = r.or_else(|| { if ctx.g.error_dlg.active() { ctx.g.error_dlg.handle(event, Dialog).into() } else { Control::Continue } }); let f = ctx.focus_mut().handle(event, Regular); ctx.queue(f); r } MinimalEvent::Rendered => { ctx.focus = Some(FocusBuilder::rebuild(&self.minimal, ctx.focus.take())); Control::Continue } MinimalEvent::Message(s) => { ctx.g.status.status(0, &*s); Control::Changed } _ => Control::Continue, }; r = r.or_else_try(|| self.minimal.event(event, ctx))?; let el = t0.elapsed()?; ctx.g.status.status(2, format!("E {:.0?}", el).to_string()); Ok(r) } }
all event-handling goes through here.
#![allow(unused)] fn main() { let mut r = match &event { ct_event!(resized) => Control::Changed, ct_event!(key press CONTROL-'q') => Control::Quit, _ => Control::Continue, }; }
This reacts to specific crossterm events. Uses the ct_event! macro, which gives a nicer syntax for event patterns.
It matches a resized event and returns a Control::Changed result to the event loop to indicate the need for repaint.
The second checks for Ctrl+Q
and just quits the application without
further ado. This is ok while developing things, but maybe a bit crude
for actual use.
The last result Control::Continue is 'nothing happened, continue with event handling'.
#![allow(unused)] fn main() { r = r.or_else(|| { if ctx.g.error_dlg.active() { ctx.g.error_dlg.handle(event, Dialog).into() } else { Control::Continue } }); }
Control implements ConsumedEvent which provides a few combinators.
Event handling can/should stop, when an event is consumed by some part of the application. ConsumedEvent::is_consumed for Control returns false for Control::Continue and true for everything else. And that's what these combinators work with.
or_else(..)
is only executed if r is Control::Continue. If the
error dialog is active, which is just some flag, it calls it's
event-handler for Dialog
style event-handling. It does whatever
it does, the one thing special about it is that Dialog
mode
consumes all events. This means, if an error dialog is displayed,
only it can react to events, everything else is shut out.
If the error dialog is not active it uses Control::Continue to show event handling can continue.
#![allow(unused)] fn main() { let f = ctx.focus_mut().handle(event, Regular); ctx.queue(f); }
Handling events for Focus is a bit special.
Focus implements an event handler for Regular
events. Regular is similar
to Dialog
seen before, and means bog-standard event handling whatever the
widget does. The speciality is that focus handling shouldn't consume the
recognized events. This is important for mouse events, where the widget might
do something useful with the same click event that focused it.
Here ctx.queue()
comes into play and provides a second path to return
results from event-handling. The primary return value from the function
call is just added to the same queue. Then everything in that queue is
worked off, before polling new events.
This way the focus change can initiate a render while the event handling function can still return whatever it wants.
#![allow(unused)] fn main() { MinimalEvent::Message(s) => { ctx.g.status.status(0, &*s); Control::Changed } }
This is a simple example for a application event. Show something in the status bar.
#![allow(unused)] fn main() { // rebuild and handle focus for each event r = r.or_else(|| { ctx.focus = Some(FocusBuilder::rebuild(&self.minimal, ctx.focus.take())); if let MinimalEvent::Event(event) = event { let f = ctx.focus_mut().handle(event, Regular); ctx.queue(f); } Control::Continue }); }
This rebuilds the Focus for each event.
TODO: add some feedback loop that can trigger this instead of doing it all the time?
#![allow(unused)] fn main() { r = r.or_else_try(|| self.minimal.event(event, ctx))?; }
Forward events.
#![allow(unused)] fn main() { Ok(r) }
And finally the result of event handling is returned to the event loop, where the event-loop acts upon it. If the result is Control::Message the event will be added to the current event-queue and processed in order. Only if the current event-queue is empty will the event loop poll for a new event. This way the ordering of event+secondary events stays deterministic.
#![allow(unused)] fn main() { fn error( &self, event: Error, ctx: &mut AppContext<'_>, ) -> Result<Control<MinimalEvent>, Error> { ctx.g.error_dlg.append(format!("{:?}", &*event).as_str()); Ok(Control::Changed) } }
All errors that end in the event loop are forwarded here for processing.
This appends the message, which for error dialog sets the dialog active too. So it will be rendered with the next render. Which is requested by returning Control::Changed.
mod minimal
This is the actual application. This example just adds a MenuLine widget and lets you quit the application via menu.
#![allow(unused)] fn main() { #[derive(Debug)] pub(crate) struct Minimal; #[derive(Debug)] pub struct MinimalState { pub menu: MenuLineState, } }
Define the necessary structs and any data/state.
#![allow(unused)] fn main() { impl AppWidget<GlobalState, MinimalMsg, Error> for Minimal { type State = MinimalState; fn render( &self, area: Rect, buf: &mut Buffer, state: &mut Self::State, ctx: &mut RenderContext<'_>, ) -> Result<(), Error> { // TODO: repaint_mask let r = Layout::new( Direction::Vertical, [ Constraint::Fill(1), // Constraint::Length(1), ], ) .split(area); let menu = MenuLine::new() .styles(ctx.g.theme.menu_style()) .item_parsed("_Quit"); menu.render(r[1], buf, &mut state.menu); Ok(()) } } }
Render the menu.
#![allow(unused)] fn main() { impl HasFocus for MinimalState { fn build(&self, builder: &mut FocusBuilder) { builder.widget(&self.menu); } } }
Implements the trait HasFocus which is the trait for container like widgets used by Focus. This adds its widgets in traversal order.
#![allow(unused)] fn main() { impl AppState<GlobalState, MinimalMsg, Error> for MinimalState { }
Implements AppState...
#![allow(unused)] fn main() { fn init( &mut self, ctx: &mut rat_salsa::AppContext<'_, GlobalState, MinimalEvent, Error>, ) -> Result<(), Error> { ctx.focus().first(); self.menu.select(Some(0)); Ok(()) } }
Init sets the focus to the first widget. And does other init work.
#![allow(unused)] fn main() { fn event( &mut self, event: &MinimalEvent, ctx: &mut rat_salsa::AppContext<'_, GlobalState, MinimalEvent, Error>, ) -> Result<Control<MinimalEvent>, Error> { let r = match event { MinimalEvent::Event(event) => { match self.menu.handle(event, Regular) { MenuOutcome::Activated(0) => Control::Quit, v => v.into(), } }, _ => Control::Continue, }; Ok(r) } }
Calls the Regular
event handler for the menu. MenuLine has its
own return type MenuOutcome
to signal anything interesting.
What interests here is that the 'Quit' menu item has been
activated. Return the according Control::Quit to end the
application.
All other values are converted to some Control value.
That's it
for a start :)
Event handling
#![allow(unused)] fn main() { fn event( &mut self, event: &MinimalEvent, ctx: &mut rat_salsa::AppContext<'_, GlobalState, MinimalEvent, Error>, ) -> Result<Control<MinimalEvent>, Error> { }
rat-salsa requires the application to define its own event type and provide conversions from every outside event that the application is interested in. The details of the conversion are left to the application, but mapping everything to an application defined enum is a good start.
#![allow(unused)] fn main() { #[derive(Debug)] pub enum MinimalEvent { Timer(TimeOut), Event(crossterm::event::Event), Rendered, Message(String), } }
rat-salsa polls all event-sources and takes note which one has an event to process. Then it takes this notes and starts with the first event-source and asks it to send its event.
The event-source converts its event-type to the application
event and sends it off to the event()
function of the
main AppState.
This results either in a specific action like 'render' or in a followup event. Followups are sent down to event() too, and result in another followup.
At some point this ends with a result Control::Continue
.
This is the point where the event-loop goes back to its
notes and asks the next event source to send its event.
Note that every event-source with an outstanding event is processed before asking all event-sources if there are new events. This prevents starving event-sources further down the list.
There are no special cases, or any routing of events,
everything goes straight to the event()
function.
The event() function gets the extra parameter ctx for access to application global data.
Result
The result is a Result<Control<Action>, Error>
, that tells
rat-salsa how to proceed.
-
Control::Continue: Continue with the next event.
-
Control::Unchanged: Event has been used, but requires no rendering. Just continues with the next event.
Within the application this is used to break early.
-
Control::Changed: Event has been used, and a render is necessary. Continues with the next event after rendering. May send a RenderedEvent immediately after the render occurred before any other events.
-
Control::Message(m): This contains a followup event. It will be put on the current events queue and processed in order. But before polling for new events.
The individual AppWidgets making up the application are quite isolated from other parts and just have access to their own state and some global application state.
All communication across AppWidgets can use this mechanism to send special events/messages.
-
Control::Quit: Ends the event-loop and resets the terminal. This returns from run_tui() and ends the application by running out of main.
Widget events 1
The widgets for rat-widget use the trait HandleEvent defined in rat-event.
#![allow(unused)] fn main() { try_flow!(match self.menu.handle(event, Regular) { MenuOutcome::Activated(0) => { Control::Quit } v => v.into(), }); }
self.menu
is the state struct for the menu widget.
It can have multiple HandleEvent implementations, typical are
Regular
and MouseOnly
. The second parameter selects the
event-handler.
-
Regular: Does all the expected event-handling and returns an Outcome value that details what has happened.
-
MouseOnly: Only uses mouse events. Generally not very useful except when you want to write your own keybindings for a widget. Then you can forward that part to the MouseOnly handler and be done with the mousey part.
See mdedit. It overrides only part of the keybindings with its own implementation and forwards the rest to the Regular handler.
-
Readonly: Text widgets have a Regular handler and a ReadOnly handler. The latter only moves and allows selections.
-
DoubleClick: Some widgets add one. Double clicks are a bit rarer and often require special attention, so this behaviour is split off from Regular handling.
-
Dialog and Popup: These are the regular handlers for dialog and popup widgets. They have some irregular behaviour, so it's good to see this immediately.
The handle functions return an outcome value that describes what has happened. This value usually is widget specific.
And there is the try_flow! macro that surrounds it all. It returns early, if the event has been consumed by the handler.
Widget events 2
If you want to use other widgets it's fine.
Consult their documentation how and if they can work with crossterm events.
Matching events
#![allow(unused)] fn main() { try_flow!(match &event { ct_event!(resized) => Control::Changed, ct_event!(key press CONTROL-'q') => Control::Quit, _ => Control::Continue, }); }
If you want to match specific events during event-handling match is great. Less so is the struct pattern for crossterm events.
That's why I started with ct_event! ...
It provides a very readable syntax, and I think it now covers all of crossterm::Event.
[!NOTE]: If you use
key press SHIFT-'q'
it will not work. It expects a capital 'Q' in that case. The same for any combination with SHIFT.
Control flow
There are some constructs to help with control flow in handler functions.
-
Trait ConsumedEvent is implemented for Control and all Outcome types.
The fn
or_else
andor_else_try
run a closure if the return value is Control::Continue;and
andand_try
run a closure if the return value is anything else. -
Macros flow! and try_flow!. These run the codeblock and return early if the result is anything but Control::Continue.
try_flow!
Ok-wraps the result, both do.into()
conversion.
Both reach similar results, and there are situations where one or the other is easier/clearer.
-
Extensive use of
From<>
.-
Widgets use the
Outcome
enum as a result, or have their derived outcome type if it is not sufficient. All extra outcome types are convertible to the base Outcome. -
On the rat-salsa side is
Control
which is modeled afterOutcome
with its own extensions. It has aFrom<T: Into<Outcome>
implementation. That means everything that is convertible to Outcome can in turn be converted to Control.This leads to
- widgets don't need to know about rat-salsa.
- rat-salsa doesn't need to know about every last widget.
-
Widgets often have action-functions that return bool to indicate 'changed'/'not changed'. There is a conversion for Outcome that maps true/false to Changed/Unchanged. So those results are integrated too.
-
-
Ord for Outcome/Control
Both implement Ord; for Outcome that's straightforward, Control ignores the Message(m) payload for this purpose.
Now it's possible to combine results
#![allow(unused)] fn main() { max(r1, r2) }
The enum values are ordered in a way that this gives a sensible result.
Extended control flow
AppContext has functions that help with application control flow.
-
add_timer()
: Sets a timer event. This returns a TimerHandle to identify a specific timer. -
queue()
andqueue_err()
: These functions add additional items to the list that will be processed after the event handler returns. The result of the event handler will be added at the end of this list too. -
spawn()
: Run a closure as a background task. Such a closure gets a cancel-token and a back-channel to report its findings.#![allow(unused)] fn main() { let cancel = ctx.spawn(move |cancel, send| { let mut data = Data::new(config); loop { // ... long task ... // report partial results send.send(Ok(Control::Message(AppMsg::Partial))); if cancel.is_canceled() { break; } } Ok(Control::Message(AppMsg::Final)) }); }
Spawns a background task. This is a move closure to own the parameters for the 'static closure. It returns a clone of the cancel token to interrupt the task if necessary.
#![allow(unused)] fn main() { let cancel = ctx.spawn(move |cancel, send| { }
Captures its parameters.
#![allow(unused)] fn main() { let mut data = Data::new(config); }
Goes into the extended calculation. This uses
send
to report a partial result as a message. At a point where canceling is sensible it checks the cancel state.#![allow(unused)] fn main() { loop { // ... long task ... // report partial results send.send(Ok(Control::Message(AppMsg::Partial))); if cancel.is_canceled() { break; } } }
Finishes with some result.
#![allow(unused)] fn main() { Ok(Control::Message(AppMsg::Final)) }
AppContext
The AppContext gives access to application wide services.
There are some builtins:
-
add_timer(): Define a timer that will send TimeOut events.
-
spawn(): Spawn long running tasks in the thread-pool. You can work with some shared memory model to get the results, but the preferred method is to return a Control::Message from the thread.
-
spawn_async(): Spawn async tasks in the tokio runtime. The result of the async task can be returned as a Control::Message too.
-
spawn_async_ext(): Gives you an extra channel to return multiple results from the async task.
-
queue(): Add results to the event-handling queue if a single return value from event-handling is not enough.
-
focus(): An instance of Focus can be stored here. Setting up the focus is the job of the application.
-
count: Gives you the frame-counter of the last render.
All application wide stuff goes into g
which is an
instance of your Global state.
RenderContext
Rendercontext is limited compared to AppContext.
It gives you g
as your Global state.
And it lets you set the screen-cursor position.
Focus
The struct Focus can do all the focus handling for your application.
As it is essential for almost any application, it got a place in AppContext.
Usage
#![allow(unused)] fn main() { if self .w_split.is_focused() { ctx.focus().next(); } else { ctx.focus().focus( & self.w_split); } }
Just some example: This queries some widget state whether it currently has the focus and jumps to the next widget /sets the focus to the same widget.
There's always a trait
or two.
-
This trait is used both for simple widgets and for containers.
- Widgets
The main functions are focus() and area().
focus() returns a clone of a FocusFlag that is part of the widgets state. It has a hidden
Rc<>
, so this is fine.The flag is close to the widget, so it's always there when you need it. As an Rc it can be used elsewhere too, say Focus.
area() returns the widgets current screen area. Which is used for mouse focus.
- Containers
Containers use the build() function of the trait to add their component widgets.
AppState
In your application you construct the current Focus for each event.
This is necessary as
- the application state might have changed
- the terminal might have been resized
and
- it's hard to track such changes at the point where they occur.
- it's cheap enough not to bother.
- there is room for optimizations later.
#![allow(unused)] fn main() { ctx.focus = Some(FocusBuilder::for_container( & self .app)); }
If you have a AppWidget that HasFocus
you can simply use
FocusBuilder to construct the current Focus. If you then set it
in the ctx
it is immediately accessible everywhere.
Events
Focus implements HandleEvent, so event handling is simple.
#![allow(unused)] fn main() { let f = Control::from( ctx.focus_mut().handle(event, Regular) ); }
Regular
event-handling for focus is
- Tab: jump to the next widget.
- Shift-Tab: jump to the previous widget.
- Mouse click: focus that widget.
Focus is independent from rat-salsa, so it returns Outcome instead of Control, thus the conversion.
Complications
handle
returns Outcome::Changed when the focus switches to a new widget and everything has to be rendered. On the other hand the focused widget might want to use the same mouse click that switched the focus to do something else.We end up with two results we need to return from the event handler.
#![allow(unused)] fn main() { let f = Control::from(ctx.focus_mut().handle(event, Regular)); let r = self .app.crossterm(event, ctx) ?; }
Here
Ord
comes to the rescue. The values of Control are constructed in order of importance, so
#![allow(unused)] fn main() { Ok(max(f, r)) }
can save the day. If focus requires Control::Changed we return this as the minimum regardless of what the rest of event handling says.
Or you can just return a second result to the event-loop using
#![allow(unused)] fn main() { let f = ctx.focus_mut().handle(event, Regular); ctx.queue(f); }
and be done with it.
Details, details
Focus
Navigation
- first(): Focus the first widget.
- next()/prev(): Change the focus.
- focus(): Focus a specific widget.
- focus_at(): Focus the widget at a position.
- expel_focus(): Make the focus go away from a widget or a container. (I use this when a popup will be hidden. It's nice if the focus doesn't just dissapear).
Debugging
- You can construct the FocusFlag with a name.
- Call Focus::enable_log()
- You might find something useful in your log-file.
Dynamic changes
You might come to a situation where
- Your state changed
- which changes the widget structure/focus order/...
- everything should still work
- which changes the widget structure/focus order/...
then you can use one of
- remove_container
- update_container
- replace_container
to change Focus without completely rebuilding it.
They reset the focus state for all widgets that are no longer part of Focus, so there is no confusion who currently owns the focus. You can call some focus function to set the new focus afterwards.
Navigation flags
This flag controls the interaction of a widget with Focus.
-
None - Widget is not reachable at all. You can manually focus() though.
-
Mouse - Widget is not keyboard reachable.
-
Regular - Normal keyboard and mouse interactions.
-
Leave - Widget can lose focus with keyboard navigation, but but not gain it.
For widgets like a MenuBar. I want a hotkey for going to the menubar, but using tab to leave it is fine.
-
Reach - Widget can gain focus, but not loose it.
There is one bastard of a widget: TextAreas. They want their tabs for themselves.
-
Lock - Focus is locked to stay with this widget.
e.g. To implement a sub-focus-cycle. When editing a table-row I want the editor widgets form a separate focus-cycle during editing. And leaving the table before either commiting or canceling the current edit is disturbing. So when the table enters edit-mode it switches to Lock and creates a new Focus with only the edit-widgets. When editing is done the table switches back into Regular mode.
-
ReachLeaveFront - Widget can be reached with normal keyboard navigation, but only left with Shift-Tab.
-
ReachLeaveBack - Inverse of ReachLeaveFront.
These flags can achieve similar effects as Lock, but leaving an exit open. I use this for MaskedTextField to navigate the different sections of an input mask. e.g. 'month' Tab 'day' Tab 'year' Tab next widget.
Widget focus
For a widget to work with Focus it must implement HasFocus.
#![allow(unused)] fn main() { pub trait HasFocusFlag { // Required methods fn focus(&self) -> FocusFlag; fn area(&self) -> Rect; // Provided methods fn area_z(&self) -> u16 { ... } fn navigable(&self) -> Navigation { ... } fn is_focused(&self) -> bool { ... } fn lost_focus(&self) -> bool { ... } fn gained_focus(&self) -> bool { ... } // fn build(&self, builder: &mut FocusBuilder) { ... } } }
- focus()
The widget state should contain a FocusFlag somewhere. It returns a clone here. The current state of the widget is always accessible during rendering and event-handling.
- area()
Area for mouse focus.
- area_z()
The z-value for the area. When you add overlapping areas the z-value is used to find out which area should be focused by a given mouse event.
-
navigable()
This indicates if/how the widget can be reached/left by Focus. It has a lot of Options, see Navigation.
-
is_focused(), lost_focus(), gained_focus()
These are for application code.
-
build()
For most widgets the default implementation will suffice.
But if you have a complex widget with inner structures, you can implement this to set up your focus requirements.
Container widgets
Container widgets are just widgets with some inner structure they want to expose.
They, too, implement HasFocus, but their main function is build() instead of just defining the focus()-flag and area().
With identity
The container widget can have a FocusFlag of its own.
impl HasFocus for FooWidget {
fn build(&self, builder: &mut FocusBuilder) {
let tag = builder.start(self);
builder.widget(&self.component_a);
builder.widget(&self.component_b);
builder.end(tag);
}
fn focus(&self) -> FocusFlag {
self.focus.clone()
}
fn area(&self) -> Rect {
self.area
}
}
If it does so,
-
focusing the container sets the focus to the first widget in the container.
-
mouse-click in the area does the same.
-
the container-flag will be a summary of the component flags. If any component has the focus, the container will have its focus-flag set too.
-
Other functions of Focus will differentiate between Widgets and Containers too.
Containers can be used to update the Focus structure after creation. There are Focus::update_container(), remove_container() and replace_container() that take containers and change the internal structure. As the Focus is rebuilt regularly this is rarely needed.
There can be state changes that will change Focus the next time it is rebuilt. But with the same state change you already want to act upon the new future structure. e.g. when changing the selected tab. Focus on tabs is created for the visible tab only, and with the tab change the focus should be transferred to the first widget on the newly visible tab.
Anonymous
A container widget can be just a bunch of components.
impl HasFocus for FooWidget {
fn build(&self, builder: &mut FocusBuilder) {
builder.widget(&self.component_a);
builder.widget(&self.component_b);
}
fn focus(&self) -> FocusFlag {
unimplemented!("not in use");
}
fn area(&self) -> Rect {
unimplemented!("not in use");
}
}
This just adds the widgets to the overall focus. focus(), area() and area_z() will not be used. navigable() is not used for containers anyway.
FocusBuilder
-
widget()
The function widget() adds widgets for the focus. They will be traversed in the order given.
-
start() and end() are used to define containers.
The two other important functions are
-
build_focus()
Takes a container widget and returns a Focus.
-
rebuild_focus()
Does the same, but takes the previous Focus too.
What it does is, it builds the new Focus and checks which widgets are no longer part of it. It resets all FocusFlags for those widgets.
A bonus is it reuses the allocations too.
Examples
minimal.rs
Starter template. Not absolut minimal but rather.
ultra.rs
Starter template. The real minimal minimal. Full rat-salsa application in less than 100 lines. You can even quit with 'q'.
To prove it.
turbo.rs
Tries to mimic Turbo Pascal 7. At least the menu is here :)
Example for an elaborate menu. It even has a submenu.
life.rs
Conways Game of Life in the terminal. There are a few sample .life files you can give it to start.
Adds an additional event-source for the event-loop to handle. It's just a simple animation ticker, but when the types align ...
theme_sample.rs
Shows the palettes for the themes.
files.rs
One percent of a file manager.
Not very complicated but shows a bigger application. The only interesting thing is it uses spawn() for listing the directories and for loading a preview.
mdedit.rs
This book has been written with it.
A small markdown editor.
Dynamic content. Complex control flow. Shows Tabs+Split. Shows TextArea. Custom event handler for a widget.
Widgets
A part of rat-salsa but still independent is rat-widget.
All can work with Focus, use crossterm events, scrolling where needed, try to not allocate for rendering.
All are regular widgets and can be used without rat-salsa.
It contains
- Button
- Choice
- Checkbox
- Radio
- Slider
- DateInput and NumberInput
- TextInput and MaskedInput
- TextArea and LineNumber
- Table
- EditTable and EditList
- Tabbed and Split
- MenuLine, PopupMenu and Menubar
- StatusLine and MsgDialog
- FileDialog
- EditList and EditTable
- View, Clipper, SinglePager, DualPager: for scrolling/page-breaking
- Month
and adapters for
- List
- Paragraph
Overlays / Popups
ratatui itself has no builtin facilities for widgets that render as overlay over other widgets.
For widgets that are only rendered as overlay, the solution is straight forward: render them after all widgets that should be below have been rendered.
That leaves widget that are only partial overlays, such as
Menubar and Split. They solve this, by not implementing any
widget trait, instead they act as widget-builders, and have
a method into_widgets()
that return two widgets. One for
the base-rendering and one for the popup. Only those are
ratatui-widgets, and they have no further configuration methods.
Event Handling
Event-handling can be structured similarly.