diff --git a/Cargo.lock b/Cargo.lock index cb6e7fdf..a0dfed1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -850,13 +850,14 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", "itoa", + "rustversion", "ryu", "static_assertions", ] @@ -988,19 +989,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - [[package]] name = "crossbeam-channel" version = "0.5.14" @@ -1046,15 +1034,15 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.8.0", "crossterm_winapi", - "libc", - "mio 0.8.11", + "mio 1.0.3", "parking_lot 0.12.3", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -2134,6 +2122,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "instant" version = "0.1.13" @@ -2186,24 +2187,6 @@ dependencies = [ "nom", ] -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2485,6 +2468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -2507,8 +2491,6 @@ dependencies = [ "clap_mangen", "colog", "confy", - "console", - "crossbeam", "crossterm", "date_time_parser", "delegate", @@ -2531,9 +2513,7 @@ dependencies = [ "predicates", "pretty_env_logger", "pubchem", - "ratatui 0.26.3", - "ratatui-textarea", - "regex", + "ratatui", "rlg 0.0.6", "rust-embed", "sea-orm", @@ -2541,9 +2521,8 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "strum 0.26.3", + "strum", "tabled", - "terminal-link", "textplots", "thiserror 2.0.11", "tokio", @@ -3233,50 +3212,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" -dependencies = [ - "bitflags 2.8.0", - "cassowary", - "crossterm", - "indoc", - "itertools 0.11.0", - "lru", - "paste", - "strum 0.25.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - -[[package]] -name = "ratatui" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags 2.8.0", "cassowary", "compact_str", "crossterm", - "itertools 0.12.1", + "indoc", + "instability", + "itertools", "lru", "paste", - "stability", - "strum 0.26.3", + "strum", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.1.14", -] - -[[package]] -name = "ratatui-textarea" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802cc8229dab704f3dcbc97799186a5b4b7aea63ecc928ffc7d17753152527b8" -dependencies = [ - "crossterm", - "ratatui 0.24.0", + "unicode-width 0.2.0", ] [[package]] @@ -3749,7 +3701,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum 0.26.3", + "strum", "thiserror 1.0.69", "time", "tracing", @@ -4012,7 +3964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio 0.8.11", + "mio 1.0.3", "signal-hook", ] @@ -4323,16 +4275,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "stability" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" -dependencies = [ - "quote", - "syn 2.0.98", -] - [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4362,35 +4304,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros 0.25.3", -] - [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.98", + "strum_macros", ] [[package]] @@ -4548,12 +4468,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "terminal-link" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "253bcead4f3aa96243b0f8fa46f9010e87ca23bd5d0c723d474ff1d2417bbdf8" - [[package]] name = "terminal_size" version = "0.4.1" @@ -4931,7 +4845,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools 0.13.0", + "itertools", "unicode-segmentation", "unicode-width 0.1.14", ] diff --git a/Cargo.toml b/Cargo.toml index 84f00cca..f6562008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,8 +62,6 @@ sea-orm-migration = { version = "1.1.0", features = [ serde_json = "1.0.134" owo-colors = "4.1.0" chrono-humanize = "0.2.3" -ratatui = "0.26.1" -crossterm = "0.27.0" async-trait = "0.1.85" indicatif = "0.17.9" tracing-subscriber = "0.3.18" @@ -72,13 +70,10 @@ tracing = "0.1.40" predicates = "3.1.3" serde_derive = "1.0.217" thiserror = "2.0.11" -ratatui-textarea = "0.4.1" -regex = "1.11.1" cached = {version = "0.54.0", features = ["disk_store", "async"]} derive_more = {version = "1.0.0", features = ["full"]} strum = "0.26.3" human-panic = "2.0.2" -crossbeam = { version = "0.8.4", features = ["crossbeam-channel"] } uuid = { version = "1.12.1", features = ["v4"] } etcetera = "0.8.0" tracing-appender = "0.2.3" @@ -89,11 +84,11 @@ pretty_env_logger = "0.5.0" textplots = "0.8.6" valuable = "0.1.1" derive = "1.0.0" -console = "0.15" -terminal-link = "0.1" +crossterm = "0.28.1" +ratatui = "0.29.0" [features] -default = ["tui"] +default = [] tui = [] [expand] diff --git a/docs/journal.md b/docs/journal.md deleted file mode 100644 index 2fa5f900..00000000 --- a/docs/journal.md +++ /dev/null @@ -1,13 +0,0 @@ -# Journal - -``` -Today -│ -● 30 seconds ago ingestion 136 (40.0 mg caffeine via oral) [>-----] [0%] -│ -● 2 hours ago ingestion 135 (40.0 mg caffeine via oral) [==----] [33%] -│ -● 4 hours ago ingestion 134 (40.0 mg caffeine via oral) [===---] [50%] -│ -│ 6 past ingestions collapsed -``` \ No newline at end of file diff --git a/docs/reference/tuirealm.md b/docs/reference/tuirealm.md deleted file mode 100644 index e427a372..00000000 --- a/docs/reference/tuirealm.md +++ /dev/null @@ -1,1498 +0,0 @@ - -# Get Started 🏁 - -- [Get Started 🏁](#get-started-) - - [An introduction to realm](#an-introduction-to-realm) - - [Key Concepts](#key-concepts) - - [MockComponent Vs. Component](#mockcomponent-vs-component) - - [The Mock Component](#the-mock-component) - - [The Component](#the-component) - - [Properties Vs. States](#properties-vs-states) - - [Events Vs. Commands](#events-vs-commands) - - [Application, Model and View](#application-model-and-view) - - [The View](#the-view) - - [Focus](#focus) - - [Model](#model) - - [The Application](#the-application) - - [Lifecycle (or "tick")](#lifecycle-or-tick) - - [Our first application](#our-first-application) - - [Let's implement the Counter](#lets-implement-the-counter) - - [Let's define the message type](#lets-define-the-message-type) - - [Let's define the component identifiers](#lets-define-the-component-identifiers) - - [Implementing the two counter components](#implementing-the-two-counter-components) - - [Implementing the model](#implementing-the-model) - - [Application setup and main loop](#application-setup-and-main-loop) - - [What's next](#whats-next) - ---- - -## An introduction to realm - -What you will learn: - -- The key concepts of tui-realm -- How to code a tui-realm application from scratch -- What makes tui-realm cool - -tui-realm is a ratatui **framework** which provides an easy way to implement stateful application. -First of all, let's give a look to the main features of tui-realm and why you should opt for this framework when building -terminal user interfaces: - -- ⌨️ **Event-driven** - - tui-realm uses the `Event -> Msg` approach, taken from Elm. **Events** are produced by some entities called `Port`, which work as event listener (such as a stdin reader or an HTTP client), which produce Events. These are then forwarded to **Components**, which will produce a **Message**. The message will cause then a certain behaviour on your application model, based on its variant. - Kinda simple and everything in your application will work around this logic, so it's really easy to implement whatever you want. - -- ⚛️ Based on **React** and **Elm** - - tui-realm is based on [React](https://reactjs.org/) and [Elm](https://elm-lang.org/). These two are kinda different as approach, but I decided to take the best from each of them to combine them in **Realm**. From React I took the **Component** concept. In realm each component represents a single graphic instance, which could potentially include some children; each component then has a **State** and some **Properties**. - From Elm I basically took every other concept implemented in Realm. I really like Elm as a language, in particular the **TEA**. - Indeed, as in Elm, in realm the lifecycle of the application is `Event -> Msg -> Update -> View -> Event -> ...` - -- 🍲 **Boilerplate** code - - tui-realm may look hard to work with at the beginning, but after a while you'll be start realizing how the code you're implementing is just boilerplate code you're copying from your previous components. - -- 🚀 Quick-setup - - Since the newest tui-realm API (1.x) tui-realm has become really easy to learn and to setup, thanks to the new `Application` data type, event listeners and to the `Terminal` helper. - -- 🎯 Single **focus** and **states** management - - Instead of managing focus and states by yourself, in realm everything is automatically managed by the **View**, which is where all components are mounted. With realm you don't have to worry about the application states and focus anymore. - -- 🙂 Easy to learn - - Thanks to the few data types exposed to the user and to the guides, it's really easy to learn tui-realm, even if you've never worked with tui or Elm before. - -- 🤖 Adaptable to any use case - - As you will learn through this guide, tui-realm exposes some advanced concepts to create your own event listener, to work with your own event and to implement complex components. - ---- - -## Key Concepts - -Let's see now what are the key concepts of tui-realm. In the introduction you've probably read about some of them in **bold**, but let's see them in details now. Key concepts are really important to understand, luckily they're easy to understand and there aren't many of them: - -- **MockComponent**: A Mock component represents a re-usable UI component, which can have some **properties** for rendering or to handle commands. It can also have its own **states**, if necessary. In practice it is a trait which exposes some methods to render and to handle properties, states and events. We'll see it in details in the next chapter. -- **Component**: A component is a wrapper around a mock component which represents a single component in your application. It directly takes events and generates messages for the application consumer. Underneath it relies on its Mock component for properties/states management and rendering. -- **State**: The state represents the current state for a component (e.g. the current text in a text input). The state depends on how the user (or other sources) interacts with the component (e.g. the user press 'a', and the char is pushed to the text input). -- **Attribute**: An attribute describes a single property in a component. The attribute shouldn't depend on the component state, but should only be configured by the user when the component is initialized. Usually a mock component exposes many attributes to be configured, and the component using the mock, sets them based on what the user requires. -- **Event**: an event is a **raw** entity describing an event caused mainly by the user (such as a keystroke), but could also be generated by an external source (we're going to talk about these last in the "advanced concepts"). -- **Message** (or usually called `Msg`): A message is a Logic event that is generated by the Components, after an **Event**. - - While the Event is *raw* (such as a keystroke), the message is application-oriented. The message is later consumed by the **Update routine**. I think an example would explain it better: let's say we have a popup component, that when `ESC` is pressed, it must report to the application to hide it. Then the event will be `Key::Esc`, it will consume it, and will return a `PopupClose` message. The mesage are totally user-defined through template types, but we'll see that later in this guide. - -- **Command** (or usually called `Cmd`): Is an entity generated by the **Component** when it receives an **Event**. It is used by the component to operate on its **MockComponent**. We'll see why of these two entities later. -- **View**: The view is where all the components are stored. The view has basically three tasks: - - **Managing components mounting/unmounting**: components are mounted into the view when they're created. The view prevents to mount duplicated components and will warn you when you try to operate on unexisting component. - - **Managing focus**: the view guarantees that only one component at a time is active. The active component is enabled with a dedicated attribute (we'll see that later) and all the events will be forwarded to it. The view keeps track of all the previous active component, so if the current active component loses focus, the previous active one is active if there's no other component to active. - - **Providing an API to operate on components**: Once components are mounted into the view, they must be accessible to the outside, but in a safe way. That's possible thanks to the bridge methods the view exposes. Since each component must be uniquely identified to be accessed, you'll have to define some IDs for your components. -- **Model**: The model is a structure you'll define for your application to implement the **Update routine**. -- **Subscription** or *Sub*: A subscription is a ruleset which tells the **application** to forward events to other components even if they're not active, based on some rules. We'll talk about subscription in advanced concepts. -- **Port**: A port is an event listener which will use a trait called `Poll` to fetch for incoming events. A port defines both the trait to call and an interval which must elapse between each call. The events are then forwarded to the subscribed components. The input listener is a port, but you may also implement for example an HTTP client, which fetches for some data. We'll see ports in advanced concepts anyway, since they're kinda uncommon to be used. -- **Event Listener**: It is a thread which polls ports to read for incoming events. The events are then reported to the **Application**. -- **Application**: The application is a super wrapper around the *View*, the *Subscriptions* and the *Event Listener*. It exposes a bridge to the view, some shorthands to the *subscriptions*; but is main function, though, is called `tick()`. As we'll see later, tick is where all the framework magic happens. -- **Update routine**: The update routine is a function, which must be implemented by the **Model** and is part of the *Update trait*. This function is as simple as important. It takes as parameter a mutable ref to the *Model*, a mutable ref to the *View* and the incoming **Message**. Based on the value of the *Message*, it provoke a certain behaviour on the Model or on the view. It is just a *match case* if you ask and it can return a *Message*, which will cause the routine to be called recursively by the application. Later, when we'll see the example you'll see how this is just cool. - ---- - -## MockComponent Vs. Component - -We've already roughly said what these two entities are, but now it's time to see them in practice. -The first thing we should remind, is that both of them are **Traits** and that by design a *Component* is also a *MockComponent*. -Let's see their definition in details: - -### The Mock Component - -The mock component is meant to be *generic* (but not too much) and *re-usable*, but at the same time with *one responsibility*. -For instance: - -- ✅ A Label which shows a single line of text makes a good mock component. -- ✅ An Input component like `` in HTML is a good mock component. Even if it can handle many input types, it still has one responsibility, is generic and is re-usable. -- ❌ An input which can handle both text, radio buttons and checks is a bad mock component. It is too generic. -- ❌ An input which takes the remote address for a server is a bad mock component. It is not generic. - -These are only guidelines, but just to give you the idea of what a mock component is. - -A mock component also handles **States** and **Props**, which are totally user-defined based on your needs. Sometimes you may even have component which don't handle any state (e.g. a label). - -In practice a mock component is a trait, with these methods to be implmented: - -```rust -pub trait MockComponent { - fn view(&mut self, frame: &mut Frame, area: Rect); - fn query(&self, attr: Attribute) -> Option; - fn attr(&mut self, attr: Attribute, value: AttrValue); - fn state(&self) -> State; - fn perform(&mut self, cmd: Cmd) -> CmdResult; -} -``` - -the trait requires you to implement: - -- *view*: a method which renders the component in the provided area. You must use `ratatui` widgets to render your component based on its properties and states. -- *query*: returns the value for a certain attribute in the component properties. -- *attr*: assign a certain attribute to the component properties. -- *state*: get the current component state. If has no state will return `State::None`. -- *perform*: Performs the provided **command** on the component. This method is called by the **Component** as we'll see later. The command should change the component states. Once the action has been processed, it must return the `CmdResult` to the **Component**. - -### The Component - -So, apparently the mock component defines everything we need handle properties, states and rendering. So why we're not done yet and we need a component trait too? - -1. MockComponent must be **generic**: mock components are distribuited in library (e.g. `tui-realm-stdlib`) and because of that, they cannot consume `Event` or produce `Message`. -2. Because of point 1, we need an entity which produces `Msg` and consume `Event`. These two entities are totally or partially user-defined, which means, they are different for each realm application. This means the component must fit to the application. -3. **It's impossible to fit a component to everybody's needs**: I tried to in tui-realm 0.x, but it was just impossible. At a certain point I just started to add properties among other properties, but eventually I ended up re-implementing stdlib components from scratch just to have some different logics. Mock Components are good because they're generic, but not too much; they must behave as dummies to us. Components are exactly what we want for the application. We want an input text, but we want that when we type 'a' it changes color. You can do it with component, you can't do it with mocks. Oh, and I almost forgot the worst thing about generalizing mocks: **keybindings**. - -Said so, what is a component? - -A component is an application specific unique implementation of a mock. Let's think for example of a form and let's say the first field is an input text which takes the username. If we think about it in HTML, it will be for sure a `` right? And so it's for many other components in your web page. So the input text will be the `MockComponent` in tui-realm. But *THAT* username input field, will be your **username input text**. The `UsernameInput` will wrapp a `Input` mock component, but based on incoming events it will operate differently on the mock and will produce different **Messages** if compared for instance to a `EmailInput`. - -So, let me state the most important thing you must keep in mind from now on: **Components are unique ❗** in your application. You **should never use the same Component more than once**. - -Let's see what a component is in practice now: - -```rust -pub trait Component: MockComponent -where - Msg: PartialEq, - UserEvent: Eq + PartialEq + Clone + PartialOrd, -{ - fn on(&mut self, ev: Event) -> Option; -} -``` - -Quite simple uh? Yep, it was my intention to make them the lighter as possible, since you'll have to implement one for each component in your view. As you can also notice, a Component requires to impl a `MockComponent` so in practice we'll also have something like: - -```rust -pub struct UsernameInput { - component: Input, // Where input implements `MockComponent` -} - -impl Component for UsernameInput { ... } -``` - -Another thing you may have noticed and that may frighten some of you are the two generic types that Component takes. -Let's see what these two types are: - -- `Msg`: defines the type of the **message** your application will handle in the **Update routine**. Indeed, in tui-realm the message are not defined in the library, but are defined by the user. We'll see this in details later in "the making of the first application". The only requirements for Message, is that it must implement `PartialEq`, since you must be able to match it in the **Update**. -- `UserEvent`: The user event defines a custom event your application can handle. As we said before tui-realm usually will send events concerning user input or terminal events, plus a special event called `Tick` (but we'll talk about it later). In addition to these though, we've seen there are other special entities called `Port`, which may return events from other source. Since tui-realm needs to know what these events are, you need to provide the type your ports will produce. - - If we give a look to the `Event` enum, everything will become clear. - - ```rust - pub enum Event - where - UserEvent: Eq + PartialEq + Clone + PartialOrd, - { - /// A keyboard event - Keyboard(KeyEvent), - /// This event is raised after the terminal window is resized - WindowResize(u16, u16), - /// A ui tick event (should be configurable) - Tick, - /// Unhandled event; Empty event - None, - /// User event; won't be used by standard library or by default input event listener; - /// but can be used in user defined ports - User(UserEvent), - } - ``` - - As you can see there is a special variant for `Event` called `User` which takes a special type `UserEvent`, which can be indeed used to use user-defined events. - - > ❗If you don't have any `UserEvent` in your application, you can declare events passing `Event`, which is an empty enum - -### Properties Vs. States - -All components are described by properties and quite often by states as well. But what is the difference between them? - -Basically **Properties** describe how the component is rendered and how it should behave. - -For example, properties are **styles**, **color** or some properties such as "should this list scroll?". -Properties are always present in a component. - -States, on the other hand, are optional and *usually* are used only by components which the user can interact with. -The state won't describe styles or how a component behaves, but the current state of a component. The state, also will usually change after the user performs a certain **Command**. - -Let's see for example how to distinguish properties from states on a component and let's say this component is a *Checkbox*: - -- The checkbox foreground and background are **Properties** (doesn't change on interaction) -- The checkbox options are **Properties** -- The current selected options are **States**. (they change on user interaction) -- The current highlighted item is a **State**. - -### Events Vs. Commands - -We've almost seen all of the aspects behind components, but we still need to talk about an important concept, which is the difference between `Event` and `Cmd`. - -If we give a look to the **Component** trait, we'll see that the method `on()` has the following signature: - -```rust -fn on(&mut self, ev: Event) -> Option; -``` - -and we know that the `Component::on()` will call the `perform()` method of its **MockComponent**, in order to update its states. The perform method has this signature instead: - -```rust -fn perform(&mut self, cmd: Cmd) -> CmdResult; -``` - -As you can see, the **Component** consumes an `Event` and produces a `Msg`, while the mock, which is called by the component, consumes a `Cmd` and produces a `CmdResult`. - -If we give a look to the two type declarations, we'll see there is a difference in terms of scope, let's give a look: - -```rust -pub enum Event -where - UserEvent: Eq + PartialEq + Clone + PartialOrd, -{ - /// A keyboard event - Keyboard(KeyEvent), - /// This event is raised after the terminal window is resized - WindowResize(u16, u16), - /// A ui tick event (should be configurable) - Tick, - /// Unhandled event; Empty event - None, - /// User event; won't be used by standard library or by default input event listener; - /// but can be used in user defined ports - User(UserEvent), -} - -pub enum Cmd { - /// Describes a "user" typed a character - Type(char), - /// Describes a "cursor" movement, or a movement of another kind - Move(Direction), - /// An expansion of `Move` which defines the scroll. The step should be defined in props, if any. - Scroll(Direction), - /// User submit field - Submit, - /// User "deleted" something - Delete, - /// User toggled something - Toggle, - /// User changed something - Change, - /// A user defined amount of time has passed and the component should be updated - Tick, - /// A user defined command type. You won't find these kind of Command in the stdlib, but you can use them in your own components. - Custom(&'static str), - /// `None` won't do anything - None, -} -``` - -For some aspects, they both look similiar, but something immediately appears clear: - -- Event is strictly bounded to the "hardware", it takes key event, terminal events or event from other sources. -- Cmd is completely independent from the hardware and terminal, and it's all about UI logic. We still have `KeyEvent`, but we've also got `Type`, `Move`, `Submit`, custom events (but not with generics) and etc. - -The reason behind this, is quite simple: **MockComponent** must be application-independent. You can create your components library and distribuite it on Github, or wherever you want, and it still must be able to work. If they took events as parameters, this couldn't be possible, since event takes in a type, which is application-dependent. - -And there's also another reason: let's imagine we have a component with a list you can scroll on and view different elements. You can scroll up/down with keys. If I wanted to create a library of components and we had events only, it wouldn't be possible to use different keybindings. Think about, with mock components I expect that in perform(), when we receive a `Cmd::Scroll(Direction::Up)` the list scrolls up, then I can implement my `Component` which will send a `Cmd::Scroll(Direction::Up)` when `W` is typed and another component which will send the same event when `` is pressed. Thanks to this mechanism, tui-realm mock components are also totally independent from key-bindings, which in tui-realm 0.x, was just a hassle. - -So whenever you implement a MockComponent, you must keep in mind that you should make it application-independent, so you must define its **Command API** and define what kind of **CmdResult** it'll produce. Then, your components must generate on whatever kind of events the `Cmd` accepted by the API, and handle the `CmdResult`, and finally, based on the value of the `CmdResult` return a certain kind of **Message** based on your application. - -We're then, finally starting to define the lifecycle of the tui-realm. This segment of the cycle, is described as `Event -> (Cmd -> CmdResult) -> Msg`. - ---- - -## Application, Model and View - -Now that we have defined what Components are, we can finally start talking about how all these components can be put together to create an application. - -In order to put everything together, we'll use three different entities, we've already briefly seen before, which are: - -- The **Application** -- The **Model** -- The **View** - -First, starting from components, the first thing we need to talk about, is the **View**. - -### The View - -The view is basically a box for all the components. All the components which are part of the same "view" (in terms of UI) must be *mounted* in the same **View**. -Each component in the view, **Must** be identified uniquely by an *identifier*, where the identifier is a type you must define (you can use an enum, you can use a String, we'll see that later). -Once a component is mounted, it won't be directly usable anymore. The view will store it as a generic `Component` and will expose a bridge to operate on all the components in the view, querying them with their identifier. - -The component will be part of the view, until you unmount the component. Once the component is unmounted, it won't be usable anymore and it'll be destroyed. - -The view is not just a list of components though, it also plays a fundamental role in the UI development, indeed, it will handle focus. Let's talk about it in the next chapter - -#### Focus - -Whenever you interact with components in a UI, there must always be a way to determine which component will handle the interaction. If I press a key, the View must be able whether to type a character in an input field or into another and this is resolved through **focus**. -Focus is just a state the view tracks. At any time, the view must know which component is currently *active* and what to do, in case that component is unmounted. - -In tui-realm, I decided to define the following rules, when working with focus: - -1. Only one component at a time can have focus -2. All events will be forwarded to the component that currently owns focus. -3. A componet to become active, must get focus via the `active()` method. -4. If a component gets focus, then its `Attribute::Focus` property becomes `AttrValue::Flag(true)` -5. If a component loses focus, then its `Attribute::Focus` property becomes `AttrValue::Flag(false)` -6. Each time a component gets focus, the previous active component, is tracked into a `Stack` (called *focus stack*) holding all the previous components owning focus. -7. If a component owning focus, is **unmounted**, the first component in the **Focus stack** becomes active -8. If a component owning focus, gets disabled via the `blur()` method, the first component in the **Focus stack** becomes active, but the *blurred* component, is not pushed into the **Focus stack**. - -Follow the following table to understand how focus works: - -| Action | Focus | Focus Stack | Components | -|----------|-------|-------------|------------| -| Active A | A | | A, B, C | -| Active B | B | A | A, B, C | -| Active C | C | B, A | A, B, C | -| Blur C | B | A | A, B, C | -| Active C | C | B, A | A, B, C | -| Active A | A | C, B | A, B, C | -| Umount A | C | B | B, C | -| Mount D | C | B | B, C, D | -| Umount B | C | | C, D | - -### Model - -The model is a struct which is totally defined by the developer implementing a tui-realm application. Its purpose is basically to update its states, perform some actions or update the view, after the components return messages. -This is done through the **Update routine**, which is defined in the **Update** trait. We'll soon see this in details, when we'll talk about the *application*, but for now, what we need to know, is what the update routine does: - -first of all your *model* must implement the **Update** trait: - -```rust -pub trait Update -where - ComponentId: Eq + PartialEq + Clone + Hash, - Msg: PartialEq, - UserEvent: Eq + PartialEq + Clone + PartialOrd, -{ - - /// update the current state handling a message from the view. - /// This function may return a Message, - /// so this function has to be intended to be call recursively if necessary - fn update( - &mut self, - view: &mut View, - msg: Option, - ) -> Option; -} -``` - -Here finally we can see almost everything put together: we have the view and we have all the 3 different custom types, defining how components are identified in the view (*ComponentId*), the *Msg* and the *UserEvent* for the *Event* type. -The update method, receives a mutable reference to the model, a mutable reference to the view and the incoming message from the component, which processed a certain type of event. -Inside the update, we'll match the msg, to perform certain operation on the model or on the view and we'll return `None` or another message, if necessary. As we'll see, if we return `Some(Msg)`, the *Application*, will re-call the routine passing as argument the last generated message. - -### The Application - -Finally we're ready to talk about the core struct of tui-realm, the **Application**. Let's see which tasks it takes care of: - -- It contains the view and exposes a bridge to it: the application contains the view itself, and provides a way to operate on it, as usual using the component identifiers. -- It handles subscriptions: as we've already seen before, subscriptions are special rules which tells the application to forward events to other components if some clauses are satisfied. -- It reads incoming events from **Ports** - -indeed as we can see, the application is a container for all these entities: - -```rust -pub struct Application -where - ComponentId: Eq + PartialEq + Clone + Hash, - Msg: PartialEq, - UserEvent: Eq + PartialEq + Clone + PartialOrd + Send + 'static, -{ - listener: EventListener, - subs: Vec>, - view: View, -} -``` - -so the application will be the sandbox for the all the entities a tui-realm app needs (and that's why is called *Application*). - -But the coolest thing here, is that all the application can be run, using a single method! This method is called `tick()` and as we'll see in the next chapter it performs all what is necessary to complete a single cycle of the application lifecycle: - -```rust -pub fn tick(&mut self, strategy: PollStrategy) -> ApplicationResult> { - // Poll event listener - let events = self.poll(strategy)?; - // Forward to active element - let mut messages: Vec = events - .iter() - .map(|x| self.forward_to_active_component(x.clone())) - .flatten() - .collect(); - // Forward to subscriptions and extend vector - messages.extend(self.forward_to_subscriptions(events)); - Ok(messages) -} -``` - -As we can quickly see, the tick method has the following workflow: - -1. The event listener is fetched according to the provided `PollStrategy` - - > ❗The poll strategy tells how to poll the event listener. You can fetch One event for cycle, or up to `n` or for a maximum amount of time - -2. All the incoming events are immediately forwarded to the current *active* component in the *view*, which may return some *messages* -3. All the incoming events are sent to all the components subscribed to that event, which satisfied the clauses described in the subscription. They, as usual, will may return some *messages* -4. The messages are returned - -Along to the tick() routine, the application provides many other functionalities, but we'll see later in the example and don't forget to checkout the documentation on rust docs. - ---- - -## Lifecycle (or "tick") - -We're finally ready to put it all together to see the entire lifecycle of the application. -Once the application is set up, the cycle of our application will be the following one: - -![lifecycle](/docs/images/lifecycle.png) - -in the image, we can see there are all the entities we've talked about earlier, which are connected through two kind of arrows, the *black* arrows defines the flow you have to implement, while the *red* arrows, follows what is already implemented and implicitly called by the application. - -So the tui-realm lifecycle consists in: - -1. the `tick()` routine is called on **Application** - 1. Ports are polled for incoming events - 2. event is forwarded to active component in the view - 3. subscriptions are queried to know whether the event should be forwarded to other components - 4. incoming messages are collected -2. Messages are returned to the caller -3. the `update()` routine is called on **Model** providing each message from component -4. The model gets updated thanks to the `update()` method -5. The `view()` function is called to render the UI - -This simple 4 steps cycle is called **Tick**, because it defines the interval between each UI refresh in fact. -Now that we know how a tui-realm application works, let's see how to implement one. - ---- - -## Our first application - -We're finally ready to set up a realm tui-realm application. In this example we're going to start with simple very simple. -The application we're going to implement is really simple, we've got two **counters**, one will track when an alphabetic character is pressed by the user and the other when a digit is pressed by the user. Both of them will track events, only when active. The active component will switch between the two counters pressing ``, while pressing `` the application will terminate. - -> ❗ Want to see something more complex? Check out [tuifeed](https://github.com/veeso/tuifeed) - -### Let's implement the Counter - -So we've said we have two Counters, one tracking alphabetic characters and one digits, so we've found a potential mock component: the **Counter**. The counter will just have a state keeping track of "times" as a number and will increment each time a certain command will be sent. -Said so, let's implement the counter: - -```rust -struct Counter { - props: Props, - states: OwnStates, -} - -impl Default for Counter { - fn default() -> Self { - Self { - props: Props::default(), - states: OwnStates::default(), - } - } -} -``` - -so the counter, as all components must have the `props` which defines its properties and in this case the counter is a stateful component, so, we need to declare its states: - -```rust -struct OwnStates { - counter: isize, -} - -impl Default for OwnStates { - fn default() -> Self { - Self { counter: 0 } - } -} - -impl OwnStates { - fn incr(&mut self) { - self.counter += 1; - } -} -``` - -Then, we'll implement an easy-to-use constructor for our mock component: - -```rust -impl Counter { - pub fn label(mut self, label: S) -> Self - where - S: AsRef, - { - self.attr( - Attribute::Title, - AttrValue::Title((label.as_ref().to_string(), Alignment::Center)), - ); - self - } - - pub fn value(mut self, n: isize) -> Self { - self.attr(Attribute::Value, AttrValue::Number(n)); - self - } - - pub fn alignment(mut self, a: Alignment) -> Self { - self.attr(Attribute::TextAlign, AttrValue::Alignment(a)); - self - } - - pub fn foreground(mut self, c: Color) -> Self { - self.attr(Attribute::Foreground, AttrValue::Color(c)); - self - } - - pub fn background(mut self, c: Color) -> Self { - self.attr(Attribute::Background, AttrValue::Color(c)); - self - } - - pub fn modifiers(mut self, m: TextModifiers) -> Self { - self.attr(Attribute::TextProps, AttrValue::TextModifiers(m)); - self - } - - pub fn borders(mut self, b: Borders) -> Self { - self.attr(Attribute::Borders, AttrValue::Borders(b)); - self - } -} -``` - -finally we can implement `MockComponent` for `Counter` - -```rust -impl MockComponent for Counter { - fn view(&mut self, frame: &mut Frame, area: Rect) { - // Check if visible - if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { - // Get properties - let text = self.states.counter.to_string(); - let alignment = self - .props - .get_or(Attribute::TextAlign, AttrValue::Alignment(Alignment::Left)) - .unwrap_alignment(); - let foreground = self - .props - .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset)) - .unwrap_color(); - let background = self - .props - .get_or(Attribute::Background, AttrValue::Color(Color::Reset)) - .unwrap_color(); - let modifiers = self - .props - .get_or( - Attribute::TextProps, - AttrValue::TextModifiers(TextModifiers::empty()), - ) - .unwrap_text_modifiers(); - let title = self - .props - .get_or( - Attribute::Title, - AttrValue::Title((String::default(), Alignment::Center)), - ) - .unwrap_title(); - let borders = self - .props - .get_or(Attribute::Borders, AttrValue::Borders(Borders::default())) - .unwrap_borders(); - let focus = self - .props - .get_or(Attribute::Focus, AttrValue::Flag(false)) - .unwrap_flag(); - frame.render_widget( - Paragraph::new(text) - .block(get_block(borders, title, focus)) - .style( - Style::default() - .fg(foreground) - .bg(background) - .add_modifier(modifiers), - ) - .alignment(alignment), - area, - ); - } - } - - fn query(&self, attr: Attribute) -> Option { - self.props.get(attr) - } - - fn attr(&mut self, attr: Attribute, value: AttrValue) { - self.props.set(attr, value); - } - - fn state(&self) -> State { - State::One(StateValue::Isize(self.states.counter)) - } - - fn perform(&mut self, cmd: Cmd) -> CmdResult { - match cmd { - Cmd::Submit => { - self.states.incr(); - CmdResult::Changed(self.state()) - } - _ => CmdResult::None, - } - } -} -``` - -so as state, we return the current value for the counter and on perform we handle the `Cmd::Submit` to increment the current value for the counter. As result we return `CmdResult::Changed()` with the state. - -So our Mock component is ready, we can now implement our two components. - -### Let's define the message type - -Before implementing the two `Component` we first need to define the messages our application will handle. -So, in on top of our application we define an enum `Msg`: - -```rust -#[derive(Debug, PartialEq)] -pub enum Msg { - AppClose, - DigitCounterChanged(isize), - DigitCounterBlur, - LetterCounterChanged(isize), - LetterCounterBlur, - /// Used to unwrap on update() - None, -} -``` - -where: - -- `AppClose` will tell to terminate the app -- `DigitCounterChanged` tells the digit counter value has changed -- `DigitCounterBlur` tells that the digit counter shall lose focus -- `LetterCounterChanged` tells the letter counter value has changed -- `LetterCounterBlur` tells that the letter counter shall lose focus - -### Let's define the component identifiers - -We need also to define the ids for our components, that will be used by the view to query mounted components. -So on top of our application, as we did for `Msg`, let's define `Id`: - -```rust -// Let's define the component ids for our application -#[derive(Debug, Eq, PartialEq, Clone, Hash)] -pub enum Id { - DigitCounter, - LetterCounter, -} -``` - -### Implementing the two counter components - -We'll have two type of counters, so we'll call them `LetterCounter` and `DigitCounter`. Let's implement them! - -First we define the `LetterCounter` with the mock component within. Since we don't need any particular behaviour for the `MockComponent` trait, we can simply derive `MockComponent`, which will implement the default implementation for MockComponent. If you want to read more read see [tuirealm_derive](https://github.com/veeso/tuirealm_derive). - -```rust -#[derive(MockComponent)] -pub struct LetterCounter { - component: Counter, -} -``` - -then we implement the constructor for the counter, that accepts the initial value and construct a `Counter` using the mock component constructor: - -```rust -impl LetterCounter { - pub fn new(initial_value: isize) -> Self { - Self { - component: Counter::default() - .alignment(Alignment::Center) - .background(Color::Reset) - .borders( - Borders::default() - .color(Color::LightGreen) - .modifiers(BorderType::Rounded), - ) - .foreground(Color::LightGreen) - .modifiers(TextModifiers::BOLD) - .value(initial_value) - .label("Letter counter"), - } - } -} -``` - -Finally we implement the `Component` trait for the `LetterCounter`, were we first convert the incoming `Event` to a consumable `Cmd`, then we call `perform()` on the mock to get the `CmdResult` in order to produce a `Msg`. -When event is `Esc` or `Tab` we directly return the `Msg` to close app or to change focus. - -```rust -impl Component for LetterCounter { - fn on(&mut self, ev: Event) -> Option { - // Get command - let cmd = match ev { - Event::Keyboard(KeyEvent { - code: Key::Char(ch), - modifiers: KeyModifiers::NONE, - }) if ch.is_alphabetic() => Cmd::Submit, - Event::Keyboard(KeyEvent { - code: Key::Tab, - modifiers: KeyModifiers::NONE, - }) => return Some(Msg::LetterCounterBlur), // Return focus lost - Event::Keyboard(KeyEvent { - code: Key::Esc, - modifiers: KeyModifiers::NONE, - }) => return Some(Msg::AppClose), - _ => Cmd::None, - }; - // perform - match self.perform(cmd) { - CmdResult::Changed(State::One(StateValue::Isize(c))) => { - Some(Msg::LetterCounterChanged(c)) - } - _ => None, - } - } -} -``` - -We'll do the same for the `DigitCounter`, but on `on()` it will check whether char is a digit, instead of alphabetic. - -### Implementing the model - -Now that we have the components, we're almost done. We can finally implement the `Model`. I made a very simple model for this example: - -```rust -pub struct Model { - /// Application - pub app: Application, - /// Indicates that the application must quit - pub quit: bool, - /// Tells whether to redraw interface - pub redraw: bool, - /// Used to draw to terminal - pub terminal: TerminalBridge, -} -``` - -> ❗ the terminal bridge is a helper struct implemented in tui-realm to interface with ratatui terminal with some helper functions. -> It also is totally backend-independent, so you won't have to know how to setup the terminal for your backend. - -Now, we'll implement the `view()` method, which will render the GUI after updating the model: - -```rust -impl Model { - pub fn view(&mut self, app: &mut Application) { - assert!(self - .terminal - .raw_mut() - .draw(|f| { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints( - [ - Constraint::Length(3), // Letter Counter - Constraint::Length(3), // Digit Counter - ] - .as_ref(), - ) - .split(f.size()); - app.view(&Id::LetterCounter, f, chunks[0]); - app.view(&Id::DigitCounter, f, chunks[1]); - }) - .is_ok()); - } -} -``` - -> ❗ If you're not familiar with the `draw()` function, please read the [ratatui](https://ratatui.rs/) documentation. - -and finally we can implement the `Update` trait: - -```rust -impl Update for Model { - fn update(&mut self, msg: Option) -> Option { - if let Some(msg) = msg { - // Set redraw - self.redraw = true; - // Match message - match msg { - Msg::AppClose => { - self.quit = true; // Terminate - None - } - Msg::Clock => None, - Msg::DigitCounterBlur => { - // Give focus to letter counter - assert!(self.app.active(&Id::LetterCounter).is_ok()); - None - } - Msg::DigitCounterChanged(v) => { - // Update label - assert!(self - .app - .attr( - &Id::Label, - Attribute::Text, - AttrValue::String(format!("DigitCounter has now value: {}", v)) - ) - .is_ok()); - None - } - Msg::LetterCounterBlur => { - // Give focus to digit counter - assert!(self.app.active(&Id::DigitCounter).is_ok()); - None - } - Msg::LetterCounterChanged(v) => { - // Update label - assert!(self - .app - .attr( - &Id::Label, - Attribute::Text, - AttrValue::String(format!("LetterCounter has now value: {}", v)) - ) - .is_ok()); - None - } - } - } else { - None - } - } -} -``` - -### Application setup and main loop - -We're almost done, let's just setup the Application in our `main()`: - -```rust -fn init_app() -> Application { - // Setup application - // NOTE: NoUserEvent is a shorthand to tell tui-realm we're not going to use any custom user event - // NOTE: the event listener is configured to use the default crossterm input listener and to raise a Tick event each second - // which we will use to update the clock - let mut app: Application = Application::init( - EventListenerCfg::default() - .default_input_listener(Duration::from_millis(20)) - .poll_timeout(Duration::from_millis(10)) - .tick_interval(Duration::from_secs(1)), - ); -} -``` - -The app requires the configuration for the `EventListener` which will poll `Ports`. We're telling the event listener to use the default input listener for our backend. `default_input_listener` will setup the default input listener for termion/crossterm or the backend you chose. Then we also define the `poll_timeout`, which describes the interval between each poll to the listener thread. - -> ❗ Here we could also define other Ports thanks to the method `port()` or setup the `Tick` producer with `tick_interval()` - -Then we can mount the two components into the view: - -```rust -assert!(app - .mount( - Id::LetterCounter, - Box::new(LetterCounter::new(0)), - Vec::default() - ) - .is_ok()); -assert!(app - .mount( - Id::DigitCounter, - Box::new(DigitCounter::new(5)), - Vec::default() - ) -.is_ok()); -``` - -> ❗ The two empty vectors are the subscriptions related to the component. (In this case none) - -Then we initilize focus: - -```rust -assert!(app.active(&Id::LetterCounter).is_ok()); -``` - -We can now setup the terminal configuration: - -```rust -let _ = model.terminal.enter_alternate_screen(); -let _ = model.terminal.enable_raw_mode(); -``` - -and we can finally implement the **main loop**: - -```rust -while !model.quit { - // Tick - match app.tick(&mut model, PollStrategy::Once) { - Err(err) => { - // Handle error... - } - Ok(messages) if messages.len() > 0 => { - // NOTE: redraw if at least one msg has been processed - model.redraw = true; - for msg in messages.into_iter() { - let mut msg = Some(msg); - while msg.is_some() { - msg = model.update(msg); - } - } - } - _ => {} - } - // Redraw - if model.redraw { - model.view(&mut app); - model.redraw = false; - } -} -``` - -On each cycle we call `tick()` on our application, with strategy `Once` and we ask the model to redraw the view only if at least one message has been processed (otherwise there shouldn't be any change to display). - -Once `quit` becomes true, the application terminates, but don't forget to finalize the terminal: - -```rust -let _ = model.terminal.leave_alternate_screen(); -let _ = model.terminal.disable_raw_mode(); -let _ = model.terminal.clear_screen(); -``` - ---- - -## What's next - -Now you know pretty much how tui-realm works and its essential concepts, but there's still a lot of features to explore, if you want to discover them, you now might be interested in these reads: - -- [Advanced concepts](advanced.md) -- [Migrating tui-realm 0.x to 1.x](migrating-legacy.md) - -# Advanced concepts - -- [Advanced concepts](#advanced-concepts) - - [Introduction](#introduction) - - [Subscriptions](#subscriptions) - - [Handle subscriptions](#handle-subscriptions) - - [Event clauses in details](#event-clauses-in-details) - - [Sub clauses in details](#sub-clauses-in-details) - - [Subscriptions lock](#subscriptions-lock) - - [Tick Event](#tick-event) - - [Ports](#ports) - - [Implementing new components](#implementing-new-components) - - [What the component should look like](#what-the-component-should-look-like) - - [Defining the component properties](#defining-the-component-properties) - - [Defining the component states](#defining-the-component-states) - - [Defining the Cmd API](#defining-the-cmd-api) - - [Rendering the component](#rendering-the-component) - - [Properties Injectors](#properties-injectors) - - [What's next](#whats-next) - ---- - -## Introduction - -This guide will introduce you to all the advanced concepts of tui-realm, that haven't been covered in the [get-started guide](get-started.md). Altough tui-realm is quite simple, it can also get quiet 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. -- What is the `Event::Tick` -- How to use custom source for events through `Ports`. -- How to implement new components - ---- - -## Subscriptions - -> A subscription is a ruleset which tells the **application** to forward events to other components even if they're not active, based on some rules. - -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 current active component, but this can be be quite annoying: - -- First, we may need a component always listening for incoming events. Imagine some loaders polling a remote server. They can't get updated only when they've got focus, they probably needs to be updated each time an event coming from the *Port* is received by the *Event listener*. Without *Subscriptions* this would be impossible. -- Sometimes is just a fact of "it's boring" and scope: in the example I had two counters, and both of them were listening for `` key to quit the application returning a `AppClose` message. But is that their responsiblity to tell whether the application should terminate? I mean, they're just counter, so they shouldn't know whether to close the app right? Besides of that, it's also really annoying to write a case for `` for each component to return `AppClose`. Having an invisible component always listening for `` to return `AppClose` would be much more comfy. - -So what is a subscription actually, and how we can create them? - -The subscription is defined as: - -```rust -pub struct Sub(EventClause, SubClause) -where - UserEvent: Eq + PartialEq + Clone + PartialOrd; -``` - -So it's a tupled structure, which takes an `EventClause` and a `SubClause`, let's dive deeper: - -- An **Event clause** is a match clause the incoming event must satisfy. As we said before the application must know whether to forward a certain *event* to a certain component. So the first thing it must check, is whether it is listening for that kind of event. - - The event clause is declared as follows: - - ```rust - pub enum EventClause - where - UserEvent: Eq + PartialEq + Clone + PartialOrd, - { - /// Forward, no matter what kind of event - Any, - /// Check whether a certain key has been pressed - Keyboard(KeyEvent), - /// Check whether window has been resized - WindowResize, - /// The event will be forwarded on a tick - Tick, - /// Event will be forwarded on this specific user event. - /// The way user event is matched, depends on its partialEq implementation - User(UserEvent), - } - ``` - -- A **Sub clause** is an additional condition that must be satisfied by the component associated to the subscription in order to forward the event: - - ```rust - pub enum SubClause { - /// Always forward event to component - Always, - /// Forward event if target component has provided attribute with the provided value - /// If the attribute doesn't exist on component, result is always `false`. - HasAttrValue(Attribute, AttrValue), - /// Forward event if target component has provided state - HasState(State), - /// Forward event if the inner clause is `false` - Not(Box), - /// Forward event if both the inner clauses are `true` - And(Box, Box), - /// Forward event if at least one of the inner clauses is `true` - Or(Box, Box), - } - ``` - -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 `EventClause` and the `SubClause` must be satisfied - -Let's see in details how to handle subscriptions and how to use clauses. - -### Handle subscriptions - -You can create subscriptions both on component mounting and whenever you want. - -To subscribe a component on `mount` it will be enough to provide a vector of `Sub` to `mount()`: - -```rust -app.mount( - Id::Clock, - Box::new( - Clock::new(SystemTime::now()) - .alignment(Alignment::Center) - .background(Color::Reset) - .foreground(Color::Cyan) - .modifiers(TextModifiers::BOLD) - ), - vec![Sub::new(SubEventClause::Tick, SubClause::Always)] -); -``` - -or you can create new subscriptions whenever you want: - -```rust -app.subscribe(&Id::Clock, Sub::new(SubEventClause::Tick, SubClause::Always)); -``` - -and if you need to remove a subscription you can unsubscribe simply with: - -```rust -app.unsubscribe(&Id::Clock, SubEventClause::Tick); -``` - -### Event clauses in details - -Event clauses are used to define for which kind of event the subscription should be set. -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 the `SubClause` then. -- `Keyboard(KeyEvent)`: in order to satisfy the clause, the incoming event must be of type `Keyboard` and the `KeyEvent` must exactly be the same. -- `WindowResize`: in order to satisfy the clause, the incoming event must be of type `WindowResize`, no matter which size the window has. -- `Tick`: in order to satisfy the clause, the incoming event must be of type `Tick`. -- `User(UserEvent)`: in order to be satisfied the incoming event must be of type of `User`. The value of `UserEvent` must match, according on how `PartialEq` is implemented for this type. - -### Sub clauses in details - -Sub clauses are verified once the event clause is satisfied, and they define some clauses that must be satisfied on the **target** component (which is the component associated to the subscription). -In particular sub clauses are: - -- `Always`: the clause is always satisfied -- `HasAttrValue(Id, Attribute, AttrValue)`: the clause is satisfied if the target component (defined in `Id`) has `Attribute` with `AttrValue` in its `Props`. -- `HasState(Id, State)`: the clause is satisfied if the target component (defined in `Id`) has `State` equal to provided state. -- `IsMounted(Id)`: the clause is satisfied if the target component (defines in `Id`) is mounted in the View. - -In addition to these, it is also possible to combine Sub clauses using expressions: - -- `Not(SubClause)`: the clause is satisfied if the inner clause is NOT satisfied (negates the result) -- `And(SubClause, SubClause)`: the clause is satisfied if both clause are satisfied -- `Or(SubClause, SubClause)`: the clause is satisfied if at least one of the two clauses is satisfied. - -Using `And` and `Or` you can create even long 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))` - -### Subscriptions lock - -It is possible to temporarily disable the subscriptions propagation. -To do so, you just need to call `application.lock_subs()`. - -Whenever you want to restore event propagation, just call `application.unlock_subs()`. - ---- - -## Tick Event - -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: - -```rust -let mut app: Application = 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 throw a `Event::Tick` which will be forwarded on `tick()` to the -current 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. - ---- - -## Ports - -Ports are basically **Event producer** which are handled by the application *Event listener*. -Usually a tui-realm application will consume only input events, or the tick event, but what if we need *some more* events? - -We may for example need a worker which fetches a remote server for data. Ports allow you to create automatized 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 *Port*: - -1. First we need to define the `UserEvent` type for our application: - - ```rust - #[derive(PartialEq, Clone, PartialOrd)] - pub enum UserEvent { - GotData(Data) - // ... other events if you need - } - - impl Eq for UserEvent {} - ``` - -2. Implement the *Port*, that I named `MyHttpClient` - - ```rust - pub struct MyHttpClient { - // ... - } - ``` - - Now we need to implement the `Poll` trait for the *Port*. - The poll trait tells the application event listener how to poll for events on a *port*: - - ```rust - impl Poll for MyHttpClient { - fn poll(&mut self) -> ListenerResult>> { - // ... do something ... - Ok(Some(Event::User(UserEvent::GotData(data)))) - } - } - ``` - -3. Port setup in application - - ```rust - let mut app: Application = Application::init( - EventListenerCfg::default() - .default_input_listener(Duration::from_millis(10)) - .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. - ---- - -## Implementing new components - -Implementing new components is actually quite simple in tui-realm, but requires you to have at least little knowledge about **tui-rs widgets**. - -In addition to tui-rs knowledge, you should also have in mind the difference between a *MockComponent* and a *Component*, in order not to implement bad components. - -Said that, let's see how to implement a component. For this example I will implement a simplified version of the `Radio` component of the stdlib. - -### What the component should look like - -The first thing we need to define is what the component should look like. -In this case the component is a box with a list of options within and you can select one, which is the user choice. -The user will be able to move through different choices and to submit one. - -### Defining the component properties - -Once we've defined what the component look like, we can start defining the component properties: - -- `Background(Color)`: will define the background color for the component -- `Borders(Borders)`: will define the borders properties for the component -- `Foreground(Color)`: will define the foreground color for the component -- `Content(Payload(Vec(String)))`: will define the possible options for the radio group -- `Title(Title)`: will define the box title -- `Value(Payload(One(Usize)))`: will work as a prop, but will update the state too, for the current selected option. - -```rust -pub struct Radio { - props: Props, - // ... -} - -impl Radio { - - // Constructors... - - pub fn foreground(mut self, fg: Color) -> Self { - self.attr(Attribute::Foreground, AttrValue::Color(fg)); - self - } - - // ... -} - -impl MockComponent for Radio { - - // ... - - fn query(&self, attr: Attribute) -> Option { - self.props.get(attr) - } - - fn attr(&mut self, attr: Attribute, value: AttrValue) { - match attr { - Attribute::Content => { - // Reset choices - let choices: Vec = value - .unwrap_payload() - .unwrap_vec() - .iter() - .map(|x| x.clone().unwrap_str()) - .collect(); - self.states.set_choices(&choices); - } - Attribute::Value => { - self.states - .select(value.unwrap_payload().unwrap_one().unwrap_usize()); - } - attr => { - self.props.set(attr, value); - } - } - } - - // ... - -} -``` - -### Defining the component states - -Since this component can be interactive and the user must be able to select a certain option, we must implement some states. -The component states must track the current selected item. For practical reasons, we also use the available choices as a state. - -```rust -struct OwnStates { - choice: usize, // Selected option - choices: Vec, // Available choices -} - -impl OwnStates { - - /// 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; - } - } - - - /// Set OwnStates 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(&mut self, spans: &[String]) { - self.choices = spans.to_vec(); - // Keep index if possible - if self.choice >= self.choices.len() { - self.choice = match self.choices.len() { - 0 => 0, - l => l - 1, - }; - } - } - - pub fn select(&mut self, i: usize) { - if i < self.choices.len() { - self.choice = i; - } - } -} -``` - -Then we can define the `state()` method - -```rust -impl MockComponent for Radio { - - // ... - - fn state(&self) -> State { - State::One(StateValue::Usize(self.states.choice)) - } - - // ... - -} -``` - -### Defining the Cmd API - -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 - -```rust -impl MockComponent 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::None, - } - } - - // ... - -} -``` - -### Rendering the component - -Finally, we can implement the component `view()` method which will render the component: - -```rust -impl MockComponent for Radio { - fn view(&mut self, render: &mut Frame, area: Rect) { - if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { - // Make choices - let choices: Vec = self - .states - .choices - .iter() - .map(|x| Spans::from(x.clone())) - .collect(); - let foreground = self - .props - .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset)) - .unwrap_color(); - let background = self - .props - .get_or(Attribute::Background, AttrValue::Color(Color::Reset)) - .unwrap_color(); - let borders = self - .props - .get_or(Attribute::Borders, AttrValue::Borders(Borders::default())) - .unwrap_borders(); - let title = self.props.get(Attribute::Title).map(|x| x.unwrap_title()); - let focus = self - .props - .get_or(Attribute::Focus, AttrValue::Flag(false)) - .unwrap_flag(); - let div = crate::utils::get_block(borders, title, focus, None); - // Make colors - let (bg, fg, block_color): (Color, Color, Color) = match focus { - true => (foreground, background, foreground), - false => (Color::Reset, foreground, Color::Reset), - }; - let radio: Tabs = Tabs::new(choices) - .block(div) - .select(self.states.choice) - .style(Style::default().fg(block_color)) - .highlight_style(Style::default().fg(fg).bg(bg)); - render.render_widget(radio, area); - } - } - - // ... -} -``` - ---- - -## Properties Injectors - -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: - -```rs -pub trait Injector -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. - ---- - -## What's next - -If you come from tui-realm 0.x and you want to migrate to tui-realm 1.x, there is a guide that explains -[how to migrate from tui-realm 0.x to 1.x](migrating-legacy.md). -Otherwise, I think you're ready to start implementing you tui-realm application right now 😉. - -If you have any question, feel free to open an issue with the `question` label and I will answer you ASAP 🙂. \ No newline at end of file diff --git a/docs/tui/ingestion-view.md b/docs/tui/ingestion-view.md new file mode 100644 index 00000000..93cc8014 --- /dev/null +++ b/docs/tui/ingestion-view.md @@ -0,0 +1,67 @@ +# Ingestion View + +Displays core ingestion data with optional phase information and substance reference data when available. + +## Required Information + +### Core Details +``` +┌─ Required Data ──────────────────────────────────────┐ +│ ID: ing_2023110114 │ +│ Substance: Caffeine │ +│ Dosage: 200mg (or "~200mg" if estimated) │ +│ Route: Oral │ +│ Time: 2023-11-01 14:00 UTC │ +└──────────────────────────────────────────────────────┘ +``` + +### Basic Timeline +``` +Ingested: 2h 15m ago +[14:00]─────────[Now] +``` + +## Optional Information + +### Substance Reference (if available) +``` +┌─ Reference Data ─────────────────────────────────────┐ +│ Database ID: sub_caffeine_001 │ +│ Known Phases: Yes │ +│ Reference Ranges Available: Yes │ +└──────────────────────────────────────────────────────┘ +``` + +### Dosage Classification (if reference available) +``` +┌─ Dosage Analysis ────────────────────────────────────┐ +│ Classification: MODERATE │ +│ Range: 200mg (within 150-300mg normal range) │ +└──────────────────────────────────────────────────────┘ +``` + +### Phase Information (if analyzable) +``` +Timeline: +O[==]C[===]P[========]F[====] +14:00 14:30 15:00 17:00 18:00 + ▲ Now + +Details: +┌─Phase────┬─Start─┬─Duration─┬─Status─┐ +│ Onset │ 14:00 │ 30m │ Done │ +│ Come-up │ 14:30 │ 30m │ Done │ +│ Peak │ 15:00 │ 120m │ Active │ +│ Offset │ 17:00 │ 60m │ - │ +└──────────┴───────┴─────────┴────────┘ +``` + +### Partial/Unknown Information Support +``` +┌─ Partial Information ───────────────────────────────┐ +│ Dosage: Range 150-200mg │ +│ Time: Between 14:00-14:30 UTC │ +│ Duration: Unknown │ +└─────────────────────────────────────────────────────┘ +``` + diff --git a/docs/tui/terminal.md b/docs/tui/terminal.md new file mode 100644 index 00000000..f1b6439d --- /dev/null +++ b/docs/tui/terminal.md @@ -0,0 +1,165 @@ +# Terminal User Interface (TUI) + +## Overview + +The terminal user interface (TUI) provides an efficient, keyboard-driven interface for monitoring and managing substance ingestions. It emphasizes clarity, real-time feedback, and intuitive navigation while maintaining high information density appropriate for terminal environments.([1](https://ratatui.rs/tutorials/hello-world/)) + +## Interface Layout + +The interface follows a classic terminal application structure with four main regions:([2](https://ratatui.rs/concepts/backends/)) + +```ascii ++-------------------- Header --------------------+ +| Title Status Bar | ++----------+-------------------------------------| +| | | +| | | +|Navigation| Main Content | +| Sidebar | | +| | | +| | | ++----------+-------------------------------------+ +| Footer | ++------------------------------------------------+ +``` + +### Regions + +1. **Header Bar** + - Application title and version + - System status (time, connectivity) + - Global navigation tabs + - Color-coded status indicators + +2. **Navigation Sidebar** + - Primary navigation menu + - Quick access to key views + - Visual indicators for active section + - Collapsible for maximum content space + +3. **Main Content Area** + - Primary information display + - Context-specific controls + - Dynamic data visualization + - Scrollable content regions + +4. **Footer** + - Context-sensitive help + - Available keyboard shortcuts + - System messages and alerts + - Operation status + + +## Screens + +- Splash Screen +- Dashboard Screen +- Ingestion List + - Ingestion View + - IngestionCreate + - Ingestion Update + - Ingestion Delete +- Settings + +## Core Components + +### Ingestion List +The primary interface for monitoring active and historical ingestions([3](https://ratatui.rs/tutorials/counter-app/single-function/)): + +- Tabular layout with sortable columns +- Real-time status updates +- Visual progress indicators +- Color-coded phase indicators +- Keyboard-driven row selection + +### Phase Timeline +Visualizes the progression of substance effects: + +```ascii +[Onset]-->[Come-up]-->[Peak]-->[Comedown]-->[Afterglow] + ▰▰▱▱▱ ▰▰▰▱▱ ▰▰▰▰▱ ▰▰▰▰▰ ▰▰▰▰▰ + 20% 40% 60% 80% 100% +``` + +### Status Indicators + +- Dosage Level: `●●●○○` (3/5 intensity) +- Phase Progress: `▰▰▰▱▱` (60% complete) +- Time Remaining: `2h 15m remaining` + +## Interaction Model + +### Keyboard Navigation + +The interface prioritizes keyboard interaction for efficiency([4](https://ratatui.rs/faq/)): + +- Vim-style navigation (h/j/k/l) +- Tab-based view switching +- Context-sensitive shortcuts +- Consistent escape patterns + +### Focus Management + +- Single active element at a time +- Clear visual focus indicators +- Logical tab ordering +- Preserved focus state across updates + +## Visual Language + +### Color System + +- **Phase Colors** + - Onset: Blue (#0366d6) + - Come-up: Green (#28a745) + - Peak: Red (#d73a49) + - Comedown: Yellow (#ffd33d) + - Afterglow: Gray (#6a737d) + +### Typography + +- Use ASCII characters for maximum compatibility +- Consistent spacing for readability +- Clear hierarchy through indentation +- Bold for emphasis and headers + +### Progress Indicators + +- Block elements for phase progress: `▰▰▰▱▱` +- Circles for intensity levels: `●●●○○` +- Brackets for grouping: `[Phase]` +- Arrows for flow: `-->` + +## Implementation Guidelines + +### Component Architecture + +Components should follow these principles([5](https://ratatui.rs/concepts/application-patterns/the-elm-architecture/)): + +1. Single Responsibility +2. Self-Contained State +3. Clear Update Pattern +4. Consistent Event Handling + +### Performance Considerations + +- Efficient rendering cycles +- Throttled updates (250ms minimum) +- Lazy loading for large datasets +- Smart refresh strategies + +### Error Handling + +- Clear error messages in footer +- Non-blocking notifications +- Graceful degradation +- Recovery options + +### Accessibility + +- High contrast color combinations +- Screen reader compatible layouts +- Keyboard-only operation +- Clear state indicators + +This documentation provides a foundation for implementing a consistent and user-friendly terminal interface that aligns with the project's goals of efficient substance ingestion monitoring and management. diff --git a/docs/visualization.md b/docs/visualization.md new file mode 100644 index 00000000..030f6c1b --- /dev/null +++ b/docs/visualization.md @@ -0,0 +1,28 @@ +# Visualizations + +## Phase Visualization + +Phase visualization should be a chart showing timeline and intensity of ingestion. + +``` +Dosage Classification: Strong (200mg) +Thresholds: [Light:50mg] [Moderate:100mg] [Strong:200mg] + +████████████████████▒░░░░░░░░░░░░░░ +▲ ▲ ▲ ▲ +│ │ │ └─ Comedown +│ │ └─ Peak +│ └─ Comeup +└─ Onset +``` + +## Ingestion Summary + +Ingestion should contain base information such as: ID, substane name, humanized dosage, humanized date of administration, route of administration and notes. + +``` +``` + +## Ingestion Progression + +Ingestion progression should be something like progress bar showing total time of activeness of substance. \ No newline at end of file diff --git a/src/cli/ingestion.rs b/src/cli/ingestion.rs index 77fdd340..41ccab3a 100644 --- a/src/cli/ingestion.rs +++ b/src/cli/ingestion.rs @@ -16,8 +16,10 @@ use crate::substance::route_of_administration::dosage::Dosage; use crate::utils::AppContext; use crate::utils::DATABASE_CONNECTION; use crate::utils::parse_date_string; +use async_std::task; use async_trait::async_trait; use chrono::DateTime; +use chrono::Duration; use chrono::Local; use chrono::TimeZone; use chrono_humanize::HumanTime; @@ -37,7 +39,9 @@ use sea_orm::QuerySelect; use sea_orm_migration::IntoSchemaManagerConnection; use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeMap; use std::fmt::Debug; +use std::fmt::Display; use std::str::FromStr; use tabled::Tabled; use tracing::Level; @@ -66,7 +70,7 @@ impl CommandHandler for LogIngestion .to_string(), ), dosage: ActiveValue::Set(self.dosage.as_base_units() as f32), - dosage_classification: Default::default(), + dosage_classification: ActiveValue::NotSet, ingested_at: ActiveValue::Set(self.ingestion_date.to_utc().naive_local()), updated_at: ActiveValue::Set(Local::now().to_utc().naive_local()), created_at: ActiveValue::Set(Local::now().to_utc().naive_local()), @@ -77,22 +81,23 @@ impl CommandHandler for LogIngestion event!(name: "ingestion_logged", Level::INFO, ingestion=?&ingestion); - println!( - "{}", - IngestionViewModel::from(ingestion.clone()).format(context.stdout_format) - ); - let analysis_query = AnalyzeIngestion::builder() .substance(self.substance_name.clone()) .date(self.ingestion_date) .dosage(self.dosage) .roa(self.route_of_administration) + .ingestion_id(Some(ingestion.id)) .build(); match analysis_query.query().await { | Ok(analysis) => { + println!( + "{}", + IngestionViewModel::from(analysis.clone()).format(context.stdout_format) + ); + if analysis.dosage_classification.is_some() { let update_model = ingestion::ActiveModel { @@ -362,6 +367,7 @@ impl CommandHandler for IngestionCommand fn display_date(date: &DateTime) -> String { HumanTime::from(*date).to_string() } + // TODO: IngestionView should be presenting a single ingestion with analysis // TODO: Add dosage classification // TODO: Add phase information @@ -379,12 +385,147 @@ pub struct IngestionViewModel #[tabled(rename = "Ingestion Date")] #[tabled(display_with = "display_date")] pub ingested_at: DateTime, - #[tabled(rename = "Current Phase")] - pub current_phase: String, + #[tabled(rename = "Dosage Classification")] + pub dosage_classification: String, +} + + +fn format_phase_information( + phases: &[ingestion_phase::Model], + ingestion_date: DateTime, +) -> (String, String, String) +{ + if phases.is_empty() + { + return ( + "Unknown".to_string(), + "No phase data".to_string(), + "No details available".to_string(), + ); + } + + let now = Local::now(); + + // Get current phase + let current_phase = phases + .iter() + .find(|phase| { + let phase_start = Local::from_utc_datetime(&Local, &phase.start_date_min); + let phase_end = Local::from_utc_datetime(&Local, &phase.end_date_max); + phase_start <= now && phase_end >= now + }) + .map(|phase| phase.classification.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + + // Format timeline + let mut timeline = String::new(); + timeline.push_str(&format!( + "Ingested: {}\n", + HumanTime::from(now.signed_duration_since(ingestion_date)) + )); + + let first_phase = phases.first().unwrap(); + let first_start = Local::from_utc_datetime(&Local, &first_phase.start_date_min); + + // Add phase markers with time indicators + let mut time_markers = vec![first_start.format("%H:%M").to_string()]; + let mut phase_line = String::new(); + + for phase in phases + { + let phase_char = &phase.classification[0..1]; + let phase_start = Local::from_utc_datetime(&Local, &phase.start_date_min); + let phase_end = Local::from_utc_datetime(&Local, &phase.end_date_max); + let duration = phase_end.signed_duration_since(phase_start); + let marker_count = (duration.num_minutes() / 15) as usize; + + phase_line.push_str(&format!("{}[{}]", "=".repeat(marker_count), phase_char)); + time_markers.push(phase_end.format("%H:%M").to_string()); + } + + timeline.push_str(&phase_line); + timeline.push_str("\n"); + timeline.push_str(&time_markers.join(" ")); + + if now > ingestion_date + { + timeline.push_str("\n"); + let current_phase_pos = phases.iter().position(|phase| { + let phase_start = Local::from_utc_datetime(&Local, &phase.start_date_min); + let phase_end = Local::from_utc_datetime(&Local, &phase.end_date_max); + phase_start <= now && phase_end >= now + }); + + if let Some(pos) = current_phase_pos + { + let spaces = " ".repeat(pos * 4 + 2); + timeline.push_str(&format!("\n{}▲ Now", spaces)); + } + } + + // Format phase details table + let mut details = String::new(); + details.push_str("┌─Phase────┬─Start─┬─Duration─┬─Status─┐\n"); + + for phase in phases + { + let phase_start = Local::from_utc_datetime(&Local, &phase.start_date_min); + let phase_end = Local::from_utc_datetime(&Local, &phase.end_date_max); + let duration = phase.duration_max; + let status = if phase_start <= now && phase_end >= now + { + "Active" + } + else if phase_end < now + { + "Done" + } + else + { + "-" + }; + + details.push_str(&format!( + "│ {:<8} │ {:>5} │ {:>8} │ {:<6} │\n", + phase.classification, + phase_start.format("%H:%M"), + format!("{}m", duration), + status + )); + } + details.push_str("└──────────┴───────┴─────────┴────────┘"); + + (current_phase, timeline, details) } impl Formatter for IngestionViewModel {} +impl Display for IngestionViewModel +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result + { + writeln!( + f, + "┌─ Required Data ──────────────────────────────────────┐" + )?; + writeln!(f, "│ ID: {:<45} │", self.id)?; + writeln!(f, "│ Substance: {:<40} │", self.substance_name)?; + writeln!(f, "│ Dosage: {:<42} │", self.dosage)?; + writeln!(f, "│ Route: {:<42} │", self.route)?; + writeln!( + f, + "│ Time: {:<43} │", + self.ingested_at.format("%Y-%m-%d %H:%M UTC") + )?; + writeln!(f, "│ Classification: {:<35} │", self.dosage_classification)?; + write!( + f, + "└──────────────────────────────────────────────────────┘" + ) + } +} + + impl From for IngestionViewModel { fn from(model: Model) -> Self @@ -394,46 +535,45 @@ impl From for IngestionViewModel model.route_of_administration.parse().unwrap_or_default(); let local_ingestion_date = Local::from_utc_datetime(&Local, &model.ingested_at); - // Get current phase synchronously - let current_phase = "Unknown".to_string(); // We'll handle phase lookup separately - Self::builder() .id(model.id) .substance_name(model.substance_name) .route(RouteOfAdministrationClassification::to_string(&route_enum)) .dosage(dosage.to_string()) .ingested_at(local_ingestion_date) - .current_phase(current_phase) + .dosage_classification( + model + .dosage_classification + .map_or("n/a".to_string(), |c| c.to_string()), + ) .build() } } + impl From for IngestionViewModel { fn from(model: crate::ingestion::model::Ingestion) -> Self { let dosage = model.dosage; - let current_phase = model - .phases - .iter() - .find(|phase| { - let now = Local::now(); - phase.start_time.start <= now && phase.end_time.end >= now - }) - .map(|phase| phase.class.to_string()) - .unwrap_or_else(|| "Unknown".to_string()); + let route_enum = model.route; Self::builder() .id(model.id.unwrap_or(0)) .substance_name(model.substance_name) - .route(RouteOfAdministrationClassification::to_string(&model.route)) + .route(RouteOfAdministrationClassification::to_string(&route_enum)) .dosage(dosage.to_string()) .ingested_at(model.ingestion_date) - .current_phase(current_phase) + .dosage_classification( + model + .dosage_classification + .map_or("n/a".to_string(), |c| c.to_string()), + ) .build() } } + #[derive(Debug, Serialize, Deserialize, Tabled, TypedBuilder)] pub struct FlatIngestion { @@ -447,16 +587,32 @@ pub struct FlatIngestion pub dosage: String, #[tabled(rename = "Dosage Classification")] pub dosage_classification: String, - #[tabled(rename = "Current Phase")] - pub current_phase: String, #[tabled(rename = "Ingestion Date")] #[tabled(display_with = "display_date")] pub ingested_at: DateTime, } + impl Formatter for FlatIngestion {} impl FormatterVector {} +impl Display for FlatIngestion +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result + { + write!( + f, + "{:<5} | {:<15} | {:<6} | {:<10} | {:<10} | {:<15} |", + self.id, + self.substance_name, + self.route, + self.dosage, + self.dosage_classification, + self.ingested_at.format("%Y-%m-%d %H:%M UTC") + ) + } +} + impl From for FlatIngestion { fn from(model: Model) -> Self @@ -466,8 +622,17 @@ impl From for FlatIngestion model.route_of_administration.parse().unwrap_or_default(); let local_ingestion_date = Local::from_utc_datetime(&Local, &model.ingested_at); - // Get current phase synchronously - let current_phase = "Unknown".to_string(); // We'll handle phase lookup separately + // Get phases synchronously + let phases = task::block_on(async { + IngestionPhase::find() + .filter(ingestion_phase::Column::IngestionId.eq(model.id.to_string())) + .all(&*DATABASE_CONNECTION) + .await + .unwrap_or_default() + }); + + let (current_phase, phase_timeline, phase_details) = + format_phase_information(&phases, local_ingestion_date); Self::builder() .id(model.id) @@ -475,35 +640,81 @@ impl From for FlatIngestion .route(RouteOfAdministrationClassification::to_string(&route_enum)) .dosage(dosage.to_string()) .ingested_at(local_ingestion_date) - .dosage_classification(model.dosage_classification.unwrap_or("n/a".to_string())) - .current_phase(current_phase) + .dosage_classification( + model + .dosage_classification + .map_or("n/a".to_string(), |c| c.to_string()), + ) .build() } } + impl From for FlatIngestion { fn from(model: crate::ingestion::model::Ingestion) -> Self { let dosage = model.dosage; + let route_enum = model.route; + let now = Local::now(); + let current_phase = model .phases .iter() - .find(|phase| { - let now = Local::now(); - phase.start_time.start <= now && phase.end_time.end >= now - }) + .find(|phase| phase.start_time.start <= now && phase.end_time.end >= now) .map(|phase| phase.class.to_string()) .unwrap_or_else(|| "Unknown".to_string()); + let phase_timeline = model + .phases + .iter() + .map(|phase| { + let phase_char = &phase.class.to_string()[0..1]; + if phase.start_time.start <= now && phase.end_time.end >= now + { + format!("[{}*]", phase_char) + } + else + { + format!("[{}]", phase_char) + } + }) + .collect::>() + .join(""); + + let phase_details = model + .phases + .iter() + .map(|phase| { + let duration = phase.duration.end.num_minutes(); + let status = if phase.start_time.start <= now && phase.end_time.end >= now + { + "Active" + } + else if phase.end_time.end < now + { + "Done" + } + else + { + "-" + }; + format!("{}: {}m ({})", phase.class, duration, status) + }) + .collect::>() + .join(", "); + Self::builder() .id(model.id.unwrap_or(0)) .substance_name(model.substance_name) - .route(RouteOfAdministrationClassification::to_string(&model.route)) + .route(RouteOfAdministrationClassification::to_string(&route_enum)) .dosage(dosage.to_string()) .ingested_at(model.ingestion_date) - .dosage_classification("n/a".to_string()) - .current_phase(current_phase) + .dosage_classification( + model + .dosage_classification + .map_or("n/a".to_string(), |c| c.to_string()), + ) .build() } } diff --git a/src/database/migrations/20250208131330_update_ingestion_model.sql b/src/database/migrations/20250208131330_update_ingestion_model.sql index b975ee6d..0af3de4a 100644 --- a/src/database/migrations/20250208131330_update_ingestion_model.sql +++ b/src/database/migrations/20250208131330_update_ingestion_model.sql @@ -13,7 +13,7 @@ CREATE TABLE `new_ingestion` `updated_at` datetime_text NOT NULL, `created_at` datetime_text NOT NULL, CHECK (`dosage_classification` IN - ('Thereshold', 'Light', 'Common', 'Strong', 'Heavy')) + ('Threshold', 'Light', 'Common', 'Strong', 'Heavy')) ); -- Copy rows from old table "ingestion" to new temporary table "new_ingestion" INSERT INTO `new_ingestion` (`id`, `substance_name`, `route_of_administration`, `dosage`, `ingested_at`, `updated_at`, diff --git a/src/database/migrations/20250211000000_fix_dosage_classification.sql b/src/database/migrations/20250211000000_fix_dosage_classification.sql new file mode 100644 index 00000000..104a49a2 --- /dev/null +++ b/src/database/migrations/20250211000000_fix_dosage_classification.sql @@ -0,0 +1,31 @@ +-- Disable foreign key constraints +PRAGMA foreign_keys = off; + +-- Create new table with correct constraint +CREATE TABLE `new_ingestion` +( + `id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, + `substance_name` varchar NOT NULL, + `route_of_administration` varchar NOT NULL, + `dosage` float NOT NULL, + `dosage_classification` text NULL, + `ingested_at` datetime_text NOT NULL, + `updated_at` datetime_text NOT NULL, + `created_at` datetime_text NOT NULL, + CHECK (`dosage_classification` IS NULL OR `dosage_classification` IN + ('Threshold', 'Light', 'Common', 'Strong', 'Heavy')) +); + +-- Copy data from old table +INSERT INTO `new_ingestion` (`id`, `substance_name`, `route_of_administration`, `dosage`, `dosage_classification`, `ingested_at`, `updated_at`, `created_at`) +SELECT `id`, `substance_name`, `route_of_administration`, `dosage`, `dosage_classification`, `ingested_at`, `updated_at`, `created_at` +FROM `ingestion`; + +-- Drop old table +DROP TABLE `ingestion`; + +-- Rename new table +ALTER TABLE `new_ingestion` RENAME TO `ingestion`; + +-- Enable foreign key constraints +PRAGMA foreign_keys = on; \ No newline at end of file diff --git a/src/database/migrator.rs b/src/database/migrator.rs index 8fea520c..a32a3862 100644 --- a/src/database/migrator.rs +++ b/src/database/migrator.rs @@ -102,6 +102,11 @@ impl MigratorTrait for Migrator "20250210175314_ingestion_phase_use_datetime", "20250210175314_ingestion_phase_use_datetime" ), + import_migration!( + M20250211000000FixDosageClassification, + "20250211000000_fix_dosage_classification", + "20250211000000_fix_dosage_classification" + ), ] } } diff --git a/src/ingestion/query.rs b/src/ingestion/query.rs index 8baa2e9c..829072fb 100644 --- a/src/ingestion/query.rs +++ b/src/ingestion/query.rs @@ -56,6 +56,8 @@ pub struct ListIngestion )] pub struct AnalyzeIngestion { + #[arg(short, long, value_name = "INGESTION_ID")] + pub ingestion_id: Option, /// Name of the substance involved in the ingestion. // #[arg( // short, @@ -97,6 +99,7 @@ impl From for AnalyzeIngestion fn from(ingestion: super::model::Ingestion) -> Self { AnalyzeIngestion { + ingestion_id: ingestion.id, substance: ingestion.substance_name, dosage: ingestion.dosage, date: ingestion.ingestion_date, @@ -142,6 +145,7 @@ impl QueryHandler for AnalyzeIngestion let date = self.date; let route = self.roa; + // Fetch substance with related data let substance = get_substance(substance_name, db) .await .map_err(|e| miette::miette!("Failed to get substance: {}", e))?; @@ -153,7 +157,7 @@ impl QueryHandler for AnalyzeIngestion route, ingestion_date: date, dosage_classification: None, - substance: substance.map(Box::new), + substance: substance.clone().map(Box::new), phases: Vec::new(), }; @@ -161,18 +165,19 @@ impl QueryHandler for AnalyzeIngestion { return Ok(ingestion); } + let substance = ingestion.substance.as_ref().unwrap(); let route_of_administration = substance.routes_of_administration.get(&self.roa); + if route_of_administration.is_none() { return Ok(ingestion); } + let route_of_administration = route_of_administration.unwrap(); let dosages = &route_of_administration.dosages; let ingestion_dosage = ingestion.dosage; - // Use route of administration information to match - // classification into ingestion's dosage. ingestion.dosage_classification = dosages .iter() .find(|(_, range)| range.contains(&ingestion_dosage)) diff --git a/src/main.rs b/src/main.rs index 6aa19c9b..a582f26b 100755 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use clap::Parser; use core::CommandHandler; use miette::Result; use std::env; - +use tracing_subscriber::util::SubscriberInitExt; mod cli; mod core; @@ -46,7 +46,7 @@ async fn main() -> Result<()> if no_args_provided && is_interactive_terminal { - return tui::tui().await.map_err(|e| miette::miette!(e.to_string())); + return tui::run(); } let cli = Cli::parse(); diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 00000000..7f715c5d --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,40 @@ +use ratatui::prelude::*; +use std::error::Error; + +pub struct App { + pub running: bool, + pub selected_tab: usize, +} + +impl Default for App { + fn default() -> Self { + Self { + running: true, + selected_tab: 0, + } + } +} + +impl App { + pub fn new() -> Self { + Self::default() + } + + pub fn tick(&mut self) {} + + pub fn quit(&mut self) { + self.running = false; + } + + pub fn on_key(&mut self, key: char) { + match key { + 'q' => self.quit(), + '1'..='4' => { + if let Some(digit) = key.to_digit(10) { + self.selected_tab = (digit as usize) - 1; + } + } + _ => {} + } + } +} \ No newline at end of file diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f0fe9bd5..c7a9fc20 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,158 +1,72 @@ -mod components; - -use crate::core::QueryHandler; -use crate::database::Ingestion; -use crate::tui::components::intensity_plot::IntensityPlot; -use chrono::Timelike; -use crossterm::event::Event; -use crossterm::event::KeyCode; -use crossterm::event::{self}; -use crossterm::execute; -use crossterm::terminal::EnterAlternateScreen; -use crossterm::terminal::LeaveAlternateScreen; -use crossterm::terminal::disable_raw_mode; -use crossterm::terminal::enable_raw_mode; -use ratatui::Frame; -use ratatui::Terminal; -use ratatui::backend::Backend; -use ratatui::layout::Constraint; -use ratatui::layout::Direction; -use ratatui::layout::Layout; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::text::Text; -use ratatui::widgets::Block; -use ratatui::widgets::Borders; -use ratatui::widgets::Paragraph; -use std::io; - -pub struct IngestionTui -{ - ingestions: Vec, +use std::io::{stdout, Stdout}; +use std::time::{Duration, Instant}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::*, +}; +use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}; +use miette::IntoDiagnostic; +use ratatui::prelude::*; + +mod app; +mod ui; + +use app::App; + +pub type Tui = Terminal>; + +pub fn init() -> miette::Result { + enable_raw_mode().into_diagnostic()?; + execute!(stdout(), EnterAlternateScreen).into_diagnostic()?; + execute!(stdout(), EnableMouseCapture).into_diagnostic()?; + execute!(stdout(), EnableBracketedPaste).into_diagnostic()?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout())).into_diagnostic()?; + terminal.clear().into_diagnostic()?; + terminal.hide_cursor().into_diagnostic()?; + Ok(terminal) } -impl IngestionTui -{ - pub fn new(ingestions: Vec) -> Self { Self { ingestions } } - - fn ui(&self, frame: &mut Frame) - { - let size = frame.size(); - - // Create main layout - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title - Constraint::Min(0), // Main content - Constraint::Length(3), // Footer - ]) - .split(size); - - // Render title - let title = Paragraph::new(Text::styled( - "🧬 Neuronek - Active Ingestions Monitor", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - frame.render_widget(title, chunks[0]); - - // Render intensity plot - let plot = IntensityPlot::new(&self.ingestions); - frame.render_widget(plot.render(), chunks[1]); - - // Render footer - let footer = Paragraph::new(Text::from(vec![Line::from(vec![ - Span::styled("q", Style::default().fg(Color::Yellow)), - Span::raw(" to quit"), - ])])) - .alignment(ratatui::layout::Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(footer, chunks[2]); - } - - pub fn run(&mut self) -> std::io::Result<()> - { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = ratatui::backend::CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let result = self.render_loop(&mut terminal); - - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - result - } - - fn render_loop( - &mut self, - terminal: &mut Terminal>, - ) -> std::io::Result<()> - { - loop - { - terminal.draw(|frame| self.ui(frame))?; - - if let Event::Key(key) = event::read()? - { - if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) - { - break; - } - } - } - Ok(()) - } +pub fn restore() -> miette::Result<()> { + execute!(stdout(), DisableBracketedPaste).into_diagnostic()?; + execute!(stdout(), DisableMouseCapture).into_diagnostic()?; + execute!(stdout(), LeaveAlternateScreen).into_diagnostic()?; + disable_raw_mode().into_diagnostic()?; + Ok(()) } -pub async fn tui() -> std::io::Result<()> -{ - let ingestions = match crate::ingestion::query::ListIngestion::default() - .query() - .await - { - | Ok(ingestions) => - { - let analyzed_ingestions = Vec::new(); - for ingestion in ingestions - { - match crate::substance::repository::get_substance( - &ingestion.substance_name, - &crate::utils::DATABASE_CONNECTION, - ) - .await - { - | Ok(Some(_substance)) => - {} - | Ok(None) => eprintln!( - "Substance not found for ingestion: {}", - ingestion.substance_name - ), - | Err(e) => eprintln!("Error fetching substance: {}", e), +pub fn run() -> miette::Result<()> { + let mut terminal = init()?; + let mut app = App::new(); + let tick_rate = Duration::from_millis(250); + let mut last_tick = Instant::now(); + + while app.running { + terminal.draw(|frame| ui::render(frame, &app)).into_diagnostic()?; + + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if event::poll(timeout).into_diagnostic()? { + if let Event::Key(key) = event::read().into_diagnostic()? { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char(c) => app.on_key(c), + KeyCode::Esc => app.quit(), + _ => {} + } } } - analyzed_ingestions } - | Err(e) => - { - eprintln!("Failed to load ingestions: {}", e); - Vec::new() + + if last_tick.elapsed() >= tick_rate { + app.tick(); + last_tick = Instant::now(); } - }; + } - let mut tui = IngestionTui::new(ingestions); - tui.run() -} + restore()?; + Ok(()) +} \ No newline at end of file diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 00000000..fa0c68b3 --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,70 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Tabs}, + Frame, +}; + +use super::app::App; + +pub fn render(frame: &mut Frame, app: &App) { + // Create the main layout + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Body + Constraint::Length(3), // Footer + ]) + .split(frame.size()); + + // Create the body layout with sidebar + let body_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(20), // Sidebar + Constraint::Percentage(80), // Main content + ]) + .split(main_layout[1]); + + render_header(frame, app, main_layout[0]); + render_sidebar(frame, app, body_layout[0]); + render_main(frame, app, body_layout[1]); + render_footer(frame, app, main_layout[2]); +} + +fn render_header(frame: &mut Frame, app: &App, area: Rect) { + let titles: Vec<_> = vec!["Home", "Substances", "Stats", "Settings"] + .iter() + .map(|t| Line::from(Span::styled(*t, Style::default().fg(Color::White)))) + .collect(); + + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title("Neuronek")) + .select(app.selected_tab) + .style(Style::default().fg(Color::White)) + .highlight_style(Style::default().fg(Color::Yellow)); + + frame.render_widget(tabs, area); +} + +fn render_sidebar(frame: &mut Frame, _app: &App, area: Rect) { + let sidebar = Block::default() + .title("Navigation") + .borders(Borders::ALL); + frame.render_widget(sidebar, area); +} + +fn render_main(frame: &mut Frame, _app: &App, area: Rect) { + let main = Block::default() + .title("Content") + .borders(Borders::ALL); + frame.render_widget(main, area); +} + +fn render_footer(frame: &mut Frame, _app: &App, area: Rect) { + let footer = Paragraph::new("Press 'q' to quit | Use 1-4 to switch tabs") + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(footer, area); +} \ No newline at end of file