📍English | 简体中文
- Advanced concepts
This guide will introduce you to all the advanced concepts of tui-realm, that haven't been covered in the Get Started guide.
Altough tui-realm is quite simple, it can also get quite powerful, thanks to all these features that we're gonna cover in this document.
What you will learn:
- How to handle Subscriptions, making some components to listen to certain events under certain circumstances, even when not focused.
- How to use custom sources for events with
Ports. - More details on how to design reusable custom components than the Get Started guide
A subscription is a ruleset which tells the Application to forward events to other components even if they're not active.
As we've already covered in the base concepts of tui-realm, the Application takes care of forwarding events from Ports to Components.
By default events are forwarded only to the currently active component, but this can be be quite annoying:
- First, we may need a component to always listen for specific incoming events. Imagine some Components need some data from a remote server. They can't get updated only when they've got focus, they probably needs to be updated each time an event is coming from the Port and is received by the Event listener. Without Subscriptions this would be impossible to do easily.
- Sometimes it is just repetetive and a choice of scoping: in the example from Get Started we had two counters,
and both of them were listening for the
<ESC>key to quit the application, returning aAppClosemessage. But should it really be their responsiblity to tell whether the application should terminate? I mean, they're just counters, so they shouldn't know whether to close the app right? Besides that, it's also really annoying to write a case for<ESC>key for each component to returnAppClose. Having an invisible component that always listens for<ESC>and returnsAppClosewould be much more comfy.
So what is a subscription actually, and how we can create them?
The subscription is defined as:
pub struct Sub<ComponentId, UserEvent>(EventClause<UserEvent>, Arc<SubClause<ComponentId>>)
where
ComponentId: Eq + PartialEq + Clone + Hash,
UserEvent: Eq + PartialEq + Clone;It takes 2 parameters:
EventClause<UserEvent>: The Event Clause, which determines what type of event to forward. This practically mirrorsEvent's Variants directly.SubClause<ComponentId>: The actual ruleset to determine, well, when to forward a given Event to the target Component.
SubClause has many variants to allow a broad range of possibilities, for example it has Always, which will always forward an event, no specific rules; it has IsMounted, which is like Always, only that it only forwards to Id if there is a component mounted to it; finally there are logical combinators like And, Or and Not. There are some other variants, which should be self-describing. More on that in a later section.
So when an event is received, if a component, that is not active, satisfies the event clause and the sub clause, then the event will be forwarded to that component too.
❗ In order to forward an event, both the
EventClauseand theSubClausemust be satisfied
Note that if a given Component is active, it will never get the event twice through being focused and through a subscription it may also have.
Let's see in details how to handle subscriptions and how to use clauses.
You can create subscriptions both on component mounting and dynamically afterward.
To subscribe a component on mount it will be enough to provide a vector of Sub to mount():
app.mount(
Id::Clock,
Box::new(
Clock::new(SystemTime::now())
.alignment(HorizontalAlignment::Center)
),
vec![Sub::new(EventClause::Tick, SubClause::Always)]
);Or you can create new subscriptions whenever you want:
app.subscribe(&Id::Clock, Sub::new(EventClause::Tick, SubClause::Always));And if you need to remove a subscription you can unsubscribe simply with:
app.unsubscribe(&Id::Clock, EventClause::Tick);❗ If you have multiple rules for a given
EventClause,unsubscribewill remove all subscriptions matching that clause.
Event clauses are used to define which kind of event the subscription should apply to. Once the application checks whether to forward an event, it must check the event clause first and verify whether it satisfies the bounds with the incoming event. The event clauses are:
Any: the event clause is satisfied, no matter what kind of event is. Everything depends on the result of theSubClausethen.Keyboard(KeyEvent): in order to satisfy the clause, the incoming event must be of typeKeyboardand theKeyEventmust exactly be the same.WindowResize: in order to satisfy the clause, the incoming event must be of typeWindowResize, no matter which size the window has.Tick: in order to satisfy the clause, the incoming event must be of typeTick.User(UserEvent): in order to be satisfied, the incoming event must be of type ofUser. The value ofUserEventmust match, according on howPartialEqis implemented for this type.Discriminant(UserEvent): in order to be statisfied, the incoming event must be of typeUser. Then theUserEvent'sstd::mem::discriminantmust match.
❗
Discriminantwill only check the top-level variants ofUserEvent, meaning that something akin toUserEvent::VariantA(OtherEnum::A)andUserEvent::Variant(OtherEnum::B)will match. If this is not behavior you want, useUser(UserEvent)and implement a customPartialEqmatching.
Sub clauses are verified once the event clause is satisfied. They define some clauses that must be satisfied to actually forward the event. In particular sub clauses are:
Always: the clause is always satisfied.HasAttrValue(Id, Attribute, AttrValue): the clause is satisfied if the Component at Id hasAttributewithAttrValue.HasState(Id, State): the clause is satisfied if the Component at Id hasStateequal to provided state.IsMounted(Id): the clause is satisfied if the Component at Id is mounted in the View.
If the current Component for the subscription does not depend on another Component being mounted, then
Alwaysshould be used overIsMounted(Self).
In addition to these, it is also possible to combine Sub clauses using logical expressions:
Not(SubClause): the clause is satisfied if the inner clause is NOT satisfied (inverts the result) (locial NOT)And(SubClause, SubClause): the clause is satisfied if both clause are satisfied (logical AND)Or(SubClause, SubClause): the clause is satisfied if at least one of the two clauses is satisfied. (logical OR)
Using And and Or you can create even longer expression and keep in mind that they are evaluated recursively, so for example:
And(Or(A, And(B, C)), And(D, Or(E, F))) is evaluated as (A || (B && C)) && (D && (E || F)).
Short-circuiting is supported where possible. For example if in Or(A, B) A evaluates to true, then B is NOT evaluated.
Similarly in And(A, B), if A evaluates to false, then B is NOT evaluated.
Additionally, to better optimize memory locality, additional variants are available:
AndMany: basically the same asAnd, but which allows more (or less) than 2 clauses at once (also supports short-circuiting)OrMany: basically the same asOr, but which allos more (or less) than 2 clauses at once (also supports short-circuiting)
It is possible to temporarily disable the event forwarding the subscriptions allow.
To do so, you just need to call Application::lock_subs().
Whenever you want to restore event propagation, just call Application::unlock_subs().
Events inbetween those 2 calls will not be forwarded once unlocked again.
The tick event is a special kind of event, which is raised by the Application with a specified interval. Whenevever initializing the Applcation you can specify the tick interval, as in the following example:
let app = Application::init(
EventListenerCfg::default()
.tick_interval(Duration::from_secs(1)),
);With the tick_interval() method, we specify the tick interval.
Each time the tick interval elapses, the application runtime will provide a Event::Tick which will be forwarded on tick() to the
currently active component and to all the components subscribed to the Tick event.
The purpose of the tick event is to schedule actions based on a certain interval. For example step a spinner consistently.
Ports are basically Event producers which are handled by the applications Event listener.
Simple tui-realm applications will only consume the core provided events, but what if we need more events?
We may for example need a worker which fetches data from a remote server. You dont want the TUI to block (not process any events) until that data fetching has finished, right? Ports allow you to create workers which will produce the events and if you set up everything correctly, your model and components will be updated.
Let's see now how to setup a custom Port:
-
First we need to define the
UserEventtype for our application:#[derive(PartialEq, Clone)] pub enum UserEvent { GotData(Data) // ... other events if you need } impl Eq for UserEvent {}
-
Implement the Port, that I named
MyHttpClientpub struct MyHttpClient { // ... }
Now we need to implement the
Polltrait for the Port. If you have ever worked with async rust, this trait is quite similar tostd::future::Future. The poll trait tells the application event listener how to poll for events on a port:impl Poll<UserEvent> for MyHttpClient { fn poll(&mut self) -> PortResult<Option<Event<UserEvent>>> { // ... do something ... Ok(Some(Event::User(UserEvent::GotData(data)))) } }
-
Add the Port to the Application
let mut app: Application<Id, Msg, UserEvent> = Application::init( EventListenerCfg::default() /* ...Other ports like "crossterm_input_listener" */ .port( Box::new(MyHttpClient::new()), Duration::from_millis(100), ), );
On the event listener constructor you can define how many ports you want. When you declare a port you need to pass a box containing the type implementing the Poll trait and an interval. The interval defines the interval between each poll to the port.
Ports in tui-realm can also be described as the Actor Pattern. You can read more about this pattern in rust in the Actors with Tokio blog post.
tui-realm also supports async ports (though only via tokio for now), which can be enabled with feature async-ports.
If your application already makes use of async, it is recommended you use async ports over sync-ports.
Implementing components is quite simple in tui-realm. This example will implement a more complex Component that what was shown in Get Started and requires the knowledge of the difference between Component and AppComponent and at least a little knowledge about ratatui widgets.
That said, let's see how to implement a more complex component. For this example we will implement a simplified version of the Radio component of the stdlib.
The first thing we need to define is what the component should look like (as in output to display).
In this case, the component should be a box with all options listed horizontally. For this we will use the ratatui widget Tabs.
Next, we need to define what the component interaction should look like. We want the user to be able to move left and right to select one and then be able to submit the currently selected option.
Once we've defined what the component should look like, we can start defining the component properties to expose:
Foreground(Color): will define the foreground color for the componentBackground(Color): will define the background color for the componentHighlightedColor(Color): will define the background color for the highlighted choiceBorders(Borders): will define the border properties for the componentContent(Payload(Vec(String))): will define the possible options for the radio groupTitle(Title): will define the box titleValue(Payload(Single(Usize))): will work as a prop, but will update the state too, for the current selected option.
pub struct Radio {
props: Props,
states: RadioState,
}
impl Radio {
// Constructors...
pub fn foreground(mut self, fg: Color) -> Self {
self.attr(Attribute::Foreground, AttrValue::Color(fg));
self
}
// Other builder functions for all the properties...
}
impl Component for Radio {
// ...
fn query<'a>(&'a self, attr: Attribute) -> Option<QueryResult<'a>> {
self.props.get_for_query(attr)
}
fn attr(&mut self, attr: Attribute, value: AttrValue) {
match attr {
Attribute::Content => {
// Overwrite choices, but keep index if possible
let choices: Vec<String> = value
.unwrap_payload()
.unwrap_vec()
.into_iter()
.map(|x| x.unwrap_str())
.collect();
self.states.set_choices(choices);
}
Attribute::Value => {
let index = value.unwrap_payload().unwrap_single().unwrap_usize();
self.states.select(index);
}
attr => {
self.props.set(attr, value);
}
}
}
// ...
}Since this component can be interactive and the user must be able to select a certain option, we must implement some state. The component states must track the current selected item. For practical reasons, we also use the available choices as a state.
struct RadioState {
choice: usize, // Selected option
choices: Vec<String>, // Available choices
}
impl RadioState {
/// Move choice index to next choice
pub fn next_choice(&mut self) {
if self.choice + 1 < self.choices.len() {
self.choice += 1;
}
}
/// Move choice index to previous choice
pub fn prev_choice(&mut self) {
if self.choice > 0 {
self.choice -= 1;
}
}
/// Select a specific index
pub fn select(&mut self, i: usize) {
if i < self.choices.len() {
self.choice = i;
}
}
/// Set RadioState choices from a vector of text spans.
/// In addition resets current selection and keep index if possible or set it to the first value
/// available.
pub fn set_choices<S: Into<Vec<String>>>(&mut self, spans: S) {
self.choices = spans.into();
// Keep index if possible
if self.choice >= self.choices.len() {
self.choice = self.choices.len().saturating_sub(1);
}
}
}Then we can define the state() method of Component:
impl Component for Radio {
// ...
fn state(&self) -> State {
State::Single(StateValue::Usize(self.states.choice))
}
// ...
}Once we've defined the component states, we can start thinking of the Command API. The Command API defines how the component behaves in front of incoming commands and what kind of result it should return.
For this component we'll handle the following commands:
- When the user moves to the right, the current choice is incremented
- When the user moves to the left, the current choice is decremented
- When the user submits, the current choice is returned
impl Component for Radio {
// ...
fn perform(&mut self, cmd: Cmd) -> CmdResult {
match cmd {
Cmd::Move(Direction::Right) => {
// Increment choice
self.states.next_choice();
// Return CmdResult On Change
CmdResult::Changed(self.state())
}
Cmd::Move(Direction::Left) => {
// Decrement choice
self.states.prev_choice();
// Return CmdResult On Change
CmdResult::Changed(self.state())
}
Cmd::Submit => {
// Return Submit
CmdResult::Submit(self.state())
}
_ => CmdResult::Invalid(cmd),
}
}
// ...
}Finally, we can implement the component view() method of Component which will render the component:
impl Component for Radio {
fn view(&mut self, render: &mut Frame, area: Rect) {
if matches!(self.props.get(Attribute::Display), Some(AttrValue::Flag(false))) {
return;
}
// Make choices
let choices: Vec<Line> = self
.states
.choices
.iter()
.map(|x| Line::from(x.as_str()))
.collect();
// Fetch all other style properties
let foreground = self
.props
.get(Attribute::Foreground)
.and_then(AttrValue::as_color)
.unwrap_or(Color::Reset);
let background = self
.props
.get(Attribute::Background)
.and_then(AttrValue::as_color)
.unwrap_or(Color::Reset);
let highlight_bg = self
.props
.get(Attribute::HighlightedColor)
.and_then(AttrValue::as_color)
.unwrap_or(Color::Reset);
let normal_style = Style::default()
.fg(foreground)
.bg(background);
let highlight_style = normal_style.patch(Style::default().bg(highlight_bg));
// assemble the Block (borders)
let borders = self
.props
.get(Attribute::Borders)
.and_then(AttrValue::as_borders)
.unwrap_or_default();
let title = self
.props
.get(Attribute::Title)
.and_then(AttrValue::as_title)
.cloned()
.unwrap_or_default();
let focus = self
.props
.get(Attribute::Focus)
.and_then(AttrValue::as_flag)
.unwrap_or(false);
let block = Block::default()
.title_top(title.content)
.borders(borders.sides)
.border_style(if focus {
borders.style()
} else {
Style::default().fg(Color::DarkGray)
});
// Finally, use a ratatui widget to draw the contents
let tabs = Tabs::new(choices)
.block(block)
.select(self.states.choice)
.style(normal_style)
.highlight_style(highlight_style);
render.render_widget(tabs, area);
}
// ...
}Properties injectors are trait objects, which must implement the Injector trait, which can provide some property (defined as a tuple of Attribute and AttrValue) for components when they're mounted.
The Injector trait is defined as follows:
pub trait Injector<ComponentId>
where
ComponentId: Eq + PartialEq + Clone + Hash,
{
fn inject(&self, id: &ComponentId) -> Vec<(Attribute, AttrValue)>;
}Then you can add an injector to your application with the add_injector() method.
Whenever you mount a new component into your view, the inject() method is called for each injector defined in your application providing as argument the id of the mounted component.
This is the end of the currently available guides for tui-realm, but consider reading further into:
- documentation of
tuirealm - documentation of
tui-realm-stdlib
If you have any question, feel free to open an issue with the question label and I will answer you ASAP 🙂.