mirror of
https://codeberg.org/JasterV/teatui.git
synced 2026-04-26 18:10:03 +00:00
Compare commits
22 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26b9b8921f | ||
|
|
39e0614412 | ||
|
|
2144832c22 | ||
|
|
dc920ba83c | ||
|
|
5ceee74b98 | ||
|
|
badcee4e35 | ||
|
|
7996d4e497 | ||
|
|
5b82c75480 | ||
|
|
52f6f0f36f | ||
|
|
5e31cb2654 | ||
|
|
4d7ba57f96 | ||
|
|
92118d929e | ||
|
|
c24d7762cb | ||
|
|
29a16f368c | ||
|
|
f32c6e9dd9 | ||
|
|
2825688c30 | ||
|
|
5e1ec63fa8 | ||
|
|
dec5c76bfd | ||
|
|
29a1ecb5f3 | ||
|
|
5d54cef53a | ||
|
|
4c5f1ace82 | ||
|
|
e0d3c1a3d2 |
27 changed files with 2575 additions and 640 deletions
17
.github/dependabot.yml
vendored
17
.github/dependabot.yml
vendored
|
|
@ -1,17 +0,0 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain dependencies for Cargo
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
90
.github/workflows/ci.yml
vendored
90
.github/workflows/ci.yml
vendored
|
|
@ -1,90 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
# ensure that the workflow is only triggered once per PR, subsequent pushes to the PR will cancel
|
||||
# and restart the workflow. See https://docs.github.com/en/actions/using-jobs/using-concurrency
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
name: fmt
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: check formatting
|
||||
run: cargo fmt -- --check
|
||||
- name: Cache Cargo dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
clippy:
|
||||
name: clippy
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- name: Run clippy action
|
||||
uses: clechasseur/rs-clippy-check@v4
|
||||
- name: Cache Cargo dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
doc:
|
||||
# run docs generation on nightly rather than stable. This enables features like
|
||||
# https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an
|
||||
# API be documented as only available in some specific platforms.
|
||||
name: doc
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust nightly
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
- name: Run cargo doc
|
||||
run: cargo doc --no-deps --all-features
|
||||
env:
|
||||
RUSTDOCFLAGS: --cfg docsrs
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: test ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
steps:
|
||||
# if your project needs OpenSSL, uncomment this to fix Windows builds.
|
||||
# it's commented out by default as the install command takes 5-10m.
|
||||
# - run: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
# if: runner.os == 'Windows'
|
||||
# - run: vcpkg install openssl:x64-windows-static-md
|
||||
# if: runner.os == 'Windows'
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
# enable this ci template to run regardless of whether the lockfile is checked in or not
|
||||
- name: cargo generate-lockfile
|
||||
if: hashFiles('Cargo.lock') == ''
|
||||
run: cargo generate-lockfile
|
||||
- name: cargo test --locked
|
||||
run: cargo test --locked --all-features --all-targets
|
||||
- name: Cache Cargo dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
|
|
@ -1,32 +0,0 @@
|
|||
name: Publish to crates.io
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
jobs:
|
||||
publish-crate:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config --global user.email "49537445+JasterV@users.noreply.github.com"
|
||||
git config --global user.name "JasterV"
|
||||
- name: Install cargo-edit
|
||||
run: |
|
||||
cargo install cargo-edit
|
||||
- name: Update crate version
|
||||
id: update-version
|
||||
run: |
|
||||
cargo set-version -p teatui $GITHUB_REF_NAME
|
||||
- name: Publish crate
|
||||
run: |
|
||||
# Publish the crate using the updated version from the previous step
|
||||
cargo publish -p teatui --allow-dirty --token ${{ secrets.CRATES_IO_TOKEN }}
|
||||
23
.woodpecker/cd.yml
Normal file
23
.woodpecker/cd.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
when:
|
||||
event: push
|
||||
branch: main
|
||||
|
||||
depends_on:
|
||||
- ci
|
||||
|
||||
steps:
|
||||
- name: Release unpublished crates
|
||||
image: codeberg.org/jasterv/rust-magic-release:latest
|
||||
pull: true
|
||||
settings:
|
||||
token:
|
||||
from_secret: CODEBERG_TOKEN
|
||||
crates_io_token:
|
||||
from_secret: CRATES_IO_TOKEN
|
||||
|
||||
- name: Update PR
|
||||
image: codeberg.org/jasterv/release-plz-update-pr:latest
|
||||
pull: true
|
||||
settings:
|
||||
token:
|
||||
from_secret: CODEBERG_TOKEN
|
||||
12
.woodpecker/ci.yml
Normal file
12
.woodpecker/ci.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
when:
|
||||
- event: [push, pull_request]
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
lint:
|
||||
image: codeberg.org/jasterv/rust-ci:1.95
|
||||
commands:
|
||||
- cargo make -p ci fmt-check
|
||||
- cargo make -p ci clippy
|
||||
- cargo make -p ci deny-check
|
||||
- cargo make -p ci docs
|
||||
41
AGENTS.md
Normal file
41
AGENTS.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# TeaTui: Agent Context & Project Overview
|
||||
|
||||
## Project Summary
|
||||
|
||||
**TeaTui** (Tea = The Elm Architecture) is an experimental Rust framework for building Terminal User Interfaces (TUIs) on top of [Ratatui](https://github.com/ratatui/ratatui). It reproduces the **Elm Architecture** (Model-Update-View) to provide a pure functional development experience in Rust.
|
||||
|
||||
## Tech Stack & Environment
|
||||
|
||||
* **Language**: Rust (Edition **2024**).
|
||||
* **Workspace Structure**: The project is organized as a workspace containing the core `teatui` library and an `examples` directory.
|
||||
* **Core Dependencies**:
|
||||
* `ratatui`: Used for terminal rendering.
|
||||
* `crossterm`: Handles terminal backend and event polling.
|
||||
* `thiserror`: Utilized for structured, descriptive error handling across the runtime.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
* **Functional Purity**: The goal is to build TUI applications in a pure-functional style, completely removing the usage of `&mut` and procedural state management.
|
||||
* **Minimal Mutability**: Mutability is discouraged in user-provided code; state transitions occur by returning new instances of the Model.
|
||||
* **Message Passing as Side Effects**: Communication between internal processes is strictly handled via `std::sync::mpsc` channels. Message passing is considered the "highest form of side effect" in the design.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
The framework operates through four concurrent actors (threads) that manage the application lifecycle:
|
||||
|
||||
1. **Model**: A single type representing the entire application state.
|
||||
2. **View Actor**: Renders the Model into the terminal. It takes a pure function `&Model -> Widget` and redraws only when the Model changes.
|
||||
3. **Update Actor**: Manages state transitions. It takes the current Model and an incoming Message, returning an `Update` enum (either `Update::Exit` or `Update::Next(NewModel, Option<Effect>)`).
|
||||
4. **Effects Actor**: Processes side effects (e.g., IO) returned by the Update actor. It can optionally return a Message to trigger further state updates.
|
||||
5. **Event Actor**: Translates raw terminal events (via `crossterm`) into application-specific Messages.
|
||||
|
||||
## Key Types & Signatures
|
||||
|
||||
* **`Update<M, E>`**: Enum signaling whether to exit or continue with a new state and optional effect.
|
||||
* **`update(model: M, msg: Msg) -> Update<M, E>`**: The core logic for state changes.
|
||||
* **`view(model: &M) -> Widget`**: The rendering logic.
|
||||
* **`effects(model: &M, effect: E) -> Option<Msg>`**: The side-effect handler.
|
||||
|
||||
## Development Guide
|
||||
|
||||
When extending the framework or building apps with it, ensure that `update` and `view` functions remain pure. Any operation involving the "outside world" (files, network, etc.) must be modeled as an `Effect` and handled within the `effects` actor.
|
||||
1641
Cargo.lock
generated
1641
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
10
Cargo.toml
10
Cargo.toml
|
|
@ -3,11 +3,13 @@ resolver = "3"
|
|||
members = ["teatui", "examples/*"]
|
||||
|
||||
[workspace.package]
|
||||
authors = ["JasterV <49537445+JasterV@users.noreply.github.com>"]
|
||||
license = "MIT"
|
||||
authors = ["JasterV jasterv@noreply.codeberg.org"]
|
||||
description = "A pure functional framework to build TUIs build on top of ratatui"
|
||||
edition = "2024"
|
||||
homepage = "https://codeberg.org/JasterV/teatui"
|
||||
license = "MIT"
|
||||
repository = "https://codeberg.org/JasterV/teatui"
|
||||
|
||||
[workspace.dependencies]
|
||||
color-eyre = "0.6.3"
|
||||
crossterm = "0.29.0"
|
||||
ratatui = { version = "0.29.0", features = ["unstable-widget-ref"] }
|
||||
ratatui = { version = "0.30" }
|
||||
|
|
|
|||
47
Makefile.toml
Normal file
47
Makefile.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
[config]
|
||||
default_to_workspace = false
|
||||
|
||||
[tasks.build]
|
||||
description = "Build binaries"
|
||||
command = "cargo"
|
||||
args = ["build", "--workspace", "--all-features"]
|
||||
|
||||
[tasks.clippy]
|
||||
description = "Runs clippy."
|
||||
clear = true
|
||||
command = "cargo"
|
||||
args = [
|
||||
"clippy",
|
||||
"--all-targets",
|
||||
"--workspace",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings",
|
||||
"-W",
|
||||
"clippy::dbg_macro",
|
||||
]
|
||||
|
||||
[tasks.fmt-check]
|
||||
description = "Runs the cargo rustfmt plugin during CI."
|
||||
command = "cargo"
|
||||
args = ["fmt", "--all", "--", "--check"]
|
||||
|
||||
[tasks.deny-check]
|
||||
description = "Runs the cargo deny plugin during CI."
|
||||
command = "cargo"
|
||||
args = ["deny", "check"]
|
||||
|
||||
[tasks.test]
|
||||
description = "Runs tests."
|
||||
clear = true
|
||||
run_task = { name = ["doc-tests", "nextest"], fork = true }
|
||||
|
||||
[tasks.nextest]
|
||||
description = "Runs tests without dependencies."
|
||||
command = "cargo"
|
||||
args = ["nextest", "run", "--no-fail-fast", "${@}"]
|
||||
|
||||
[tasks.doc-tests]
|
||||
description = "Run doc tests"
|
||||
command = "cargo"
|
||||
args = ["test", "--doc"]
|
||||
50
deny.toml
Normal file
50
deny.toml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
[graph]
|
||||
targets = []
|
||||
all-features = false
|
||||
no-default-features = false
|
||||
|
||||
[output]
|
||||
feature-depth = 1
|
||||
|
||||
[advisories]
|
||||
version = 2
|
||||
# The path where the advisory database is cloned/fetched into
|
||||
db-path = "~/.cargo/advisory-db"
|
||||
# The url(s) of the advisory databases to use
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# The lint level for crates that have been yanked from their source registry
|
||||
yanked = "warn"
|
||||
ignore = []
|
||||
|
||||
|
||||
[licenses]
|
||||
allow = ["Unicode-3.0", "Apache-2.0", "MIT", "Zlib"]
|
||||
confidence-threshold = 0.8
|
||||
exceptions = []
|
||||
|
||||
[licenses.private]
|
||||
ignore = false
|
||||
registries = []
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
wildcards = "allow"
|
||||
highlight = "all"
|
||||
workspace-default-features = "allow"
|
||||
external-default-features = "allow"
|
||||
allow = []
|
||||
deny = []
|
||||
|
||||
skip = []
|
||||
skip-tree = []
|
||||
|
||||
[sources]
|
||||
unknown-registry = "warn"
|
||||
unknown-git = "warn"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
allow-git = []
|
||||
|
||||
[sources.allow-org]
|
||||
github = []
|
||||
gitlab = []
|
||||
bitbucket = []
|
||||
|
|
@ -4,9 +4,9 @@ publish = false
|
|||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
color-eyre.workspace = true
|
||||
crossterm.workspace = true
|
||||
ratatui.workspace = true
|
||||
teatui = { path = "../../teatui" }
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
use color_eyre::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::{
|
||||
style::Stylize,
|
||||
text::Line,
|
||||
widgets::{Block, Paragraph},
|
||||
};
|
||||
use teatui::{Update, View};
|
||||
use teatui::{ProgramError, update::Update};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
let result = teatui::start(Model::default(), update, view, run_effects);
|
||||
result
|
||||
fn main() -> Result<(), ProgramError<Model, Message, Effect>> {
|
||||
teatui::start(|| (Model::default(), None), update, view, run_effects)
|
||||
}
|
||||
|
||||
/// Defines the state of the application
|
||||
|
|
@ -38,9 +35,11 @@ impl Model {
|
|||
}
|
||||
|
||||
/// Possible side effects to execute
|
||||
#[derive(Debug)]
|
||||
pub enum Effect {}
|
||||
|
||||
/// Messages that represent a change of state in the application
|
||||
#[derive(Debug)]
|
||||
pub enum Message {
|
||||
IncCounter,
|
||||
DecCounter,
|
||||
|
|
@ -93,23 +92,23 @@ impl From<crossterm::event::Event> for Message {
|
|||
///
|
||||
/// Given the current state (model) and an incoming message from the outside world,
|
||||
/// return the next updated state
|
||||
pub fn update(model: Model, msg: Message) -> Result<Update<Model, Effect>> {
|
||||
pub fn update(model: Model, msg: Message) -> Update<Model, Effect> {
|
||||
match msg {
|
||||
Message::Exit => Ok(Update::Exit),
|
||||
Message::NoOp => Ok(Update::Next(model)),
|
||||
Message::IncCounter => Ok(Update::Next(Model::increment_counter(model))),
|
||||
Message::DecCounter => Ok(Update::Next(Model::decrement_counter(model))),
|
||||
Message::Exit => Update::Exit,
|
||||
Message::NoOp => Update::Next(model, None),
|
||||
Message::IncCounter => Update::Next(Model::increment_counter(model), None),
|
||||
Message::DecCounter => Update::Next(Model::decrement_counter(model), None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_effects(_model: &Model, _effect: Effect) -> Result<Option<Message>> {
|
||||
Ok(None)
|
||||
pub fn run_effects(_model: Model, _effect: Effect) -> Option<Message> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Elm-like View function.
|
||||
///
|
||||
/// Given the current state (read-only), return a drawable widget.
|
||||
pub fn view(model: &Model) -> Result<View> {
|
||||
pub fn view(model: Model) -> Paragraph<'static> {
|
||||
let counter = model.counter;
|
||||
|
||||
let title = Line::from("Ratatui Actor-based Counter")
|
||||
|
|
@ -125,9 +124,7 @@ Counter: {counter}
|
|||
Press `Esc`, `Ctrl-C` or `q` to stop running."#
|
||||
);
|
||||
|
||||
let widget = Paragraph::new(text)
|
||||
Paragraph::new(text)
|
||||
.block(Block::bordered().title(title))
|
||||
.centered();
|
||||
|
||||
Ok(View::new(widget))
|
||||
.centered()
|
||||
}
|
||||
|
|
|
|||
13
examples/tabs/Cargo.toml
Normal file
13
examples/tabs/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "tabs"
|
||||
publish = false
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crossterm.workspace = true
|
||||
ratatui.workspace = true
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
teatui = { path = "../../teatui" }
|
||||
232
examples/tabs/src/main.rs
Normal file
232
examples/tabs/src/main.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Stylize, palette::tailwind},
|
||||
symbols,
|
||||
text::Line,
|
||||
widgets::{Block, Padding, Paragraph, Tabs, Widget},
|
||||
};
|
||||
use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
|
||||
use teatui::{ProgramError, update::Update};
|
||||
|
||||
fn main() -> Result<(), ProgramError<Model, Message, ()>> {
|
||||
teatui::start(init, update, view, |_, _| None)
|
||||
}
|
||||
|
||||
fn init() -> (Model, Option<()>) {
|
||||
(Model::default(), None)
|
||||
}
|
||||
|
||||
/// Defines the state of the application
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct Model {
|
||||
selected_tab: SelectedTab,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn next_tab(self) -> Self {
|
||||
Model {
|
||||
selected_tab: self.selected_tab.next(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_tab(self) -> Self {
|
||||
Model {
|
||||
selected_tab: self.selected_tab.previous(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, Display, FromRepr, EnumIter)]
|
||||
enum SelectedTab {
|
||||
#[default]
|
||||
#[strum(to_string = "Tab 1")]
|
||||
Tab1,
|
||||
#[strum(to_string = "Tab 2")]
|
||||
Tab2,
|
||||
#[strum(to_string = "Tab 3")]
|
||||
Tab3,
|
||||
#[strum(to_string = "Tab 4")]
|
||||
Tab4,
|
||||
}
|
||||
|
||||
impl SelectedTab {
|
||||
/// Get the previous tab, if there is no previous tab return the current tab.
|
||||
fn previous(self) -> Self {
|
||||
let current_index: usize = self as usize;
|
||||
let previous_index = current_index.saturating_sub(1);
|
||||
Self::from_repr(previous_index).unwrap_or(self)
|
||||
}
|
||||
|
||||
/// Get the next tab, if there is no next tab return the current tab.
|
||||
fn next(self) -> Self {
|
||||
let current_index = self as usize;
|
||||
let next_index = current_index.saturating_add(1);
|
||||
Self::from_repr(next_index).unwrap_or(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages that represent a change of state in the application
|
||||
#[derive(Debug)]
|
||||
enum Message {
|
||||
NextTab,
|
||||
PreviousTab,
|
||||
Exit,
|
||||
NoOp,
|
||||
}
|
||||
|
||||
impl From<crossterm::event::Event> for Message {
|
||||
fn from(value: Event) -> Self {
|
||||
match value {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Esc | KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::Exit,
|
||||
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('l') | KeyCode::Right,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::NextTab,
|
||||
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('h') | KeyCode::Left,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::PreviousTab,
|
||||
|
||||
Event::FocusGained
|
||||
| Event::FocusLost
|
||||
| Event::Key(_)
|
||||
| Event::Mouse(_)
|
||||
| Event::Paste(_)
|
||||
| Event::Resize(_, _) => Self::NoOp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Elm-like update function.
|
||||
///
|
||||
/// Given the current state (model) and an incoming message from the outside world,
|
||||
/// return the next updated state
|
||||
fn update(model: Model, msg: Message) -> Update<Model, ()> {
|
||||
match msg {
|
||||
Message::Exit => Update::Exit,
|
||||
Message::NoOp => Update::Next(model, None),
|
||||
Message::NextTab => Update::Next(Model::next_tab(model), None),
|
||||
Message::PreviousTab => Update::Next(Model::previous_tab(model), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Elm-like View function.
|
||||
///
|
||||
/// Given the current state, return a drawable widget.
|
||||
fn view(model: Model) -> AppWidget {
|
||||
AppWidget { model }
|
||||
}
|
||||
|
||||
struct AppWidget {
|
||||
model: Model,
|
||||
}
|
||||
|
||||
impl Widget for AppWidget {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
use Constraint::{Length, Min};
|
||||
let vertical = Layout::vertical([Length(1), Min(0), Length(1)]);
|
||||
let [header_area, inner_area, footer_area] = vertical.areas(area);
|
||||
|
||||
let horizontal = Layout::horizontal([Min(0), Length(20)]);
|
||||
let [tabs_area, title_area] = horizontal.areas(header_area);
|
||||
|
||||
"Ratatui Tabs Example".bold().render(title_area, buf);
|
||||
|
||||
render_tabs(&self.model.selected_tab, tabs_area, buf);
|
||||
|
||||
self.model.selected_tab.render(inner_area, buf);
|
||||
|
||||
Line::raw("◄ ► to change tab | Press q to quit")
|
||||
.centered()
|
||||
.render(footer_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tabs(selected_tab: &SelectedTab, area: Rect, buf: &mut Buffer) {
|
||||
let titles = SelectedTab::iter().map(SelectedTab::title);
|
||||
let highlight_style = (Color::default(), selected_tab.palette().c700);
|
||||
let selected_tab_index = (*selected_tab) as usize;
|
||||
|
||||
Tabs::new(titles)
|
||||
.highlight_style(highlight_style)
|
||||
.select(selected_tab_index)
|
||||
.padding("", "")
|
||||
.divider(" ")
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
impl Widget for SelectedTab {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
// in a real app these might be separate widgets
|
||||
match self {
|
||||
Self::Tab1 => self.render_tab0(area, buf),
|
||||
Self::Tab2 => self.render_tab1(area, buf),
|
||||
Self::Tab3 => self.render_tab2(area, buf),
|
||||
Self::Tab4 => self.render_tab3(area, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectedTab {
|
||||
/// Return tab's name as a styled `Line`
|
||||
fn title(self) -> Line<'static> {
|
||||
format!(" {self} ")
|
||||
.fg(tailwind::SLATE.c200)
|
||||
.bg(self.palette().c900)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn render_tab0(self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new("Hello, World!")
|
||||
.block(self.block())
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_tab1(self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new("Welcome to the Ratatui tabs example!")
|
||||
.block(self.block())
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_tab2(self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new("Look! I'm different than others!")
|
||||
.block(self.block())
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_tab3(self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new("I know, these are some basic changes. But I think you got the main idea.")
|
||||
.block(self.block())
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
/// A block surrounding the tab's content
|
||||
fn block(self) -> Block<'static> {
|
||||
Block::bordered()
|
||||
.border_set(symbols::border::PROPORTIONAL_TALL)
|
||||
.padding(Padding::horizontal(1))
|
||||
.border_style(self.palette().c700)
|
||||
}
|
||||
|
||||
const fn palette(self) -> tailwind::Palette {
|
||||
match self {
|
||||
Self::Tab1 => tailwind::BLUE,
|
||||
Self::Tab2 => tailwind::EMERALD,
|
||||
Self::Tab3 => tailwind::INDIGO,
|
||||
Self::Tab4 => tailwind::RED,
|
||||
}
|
||||
}
|
||||
}
|
||||
13
examples/todo-list/Cargo.toml
Normal file
13
examples/todo-list/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "todo_list"
|
||||
publish = false
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crossterm.workspace = true
|
||||
ratatui.workspace = true
|
||||
strum = "0.28.0"
|
||||
teatui = { path = "../../teatui" }
|
||||
18
examples/todo-list/src/main.rs
Normal file
18
examples/todo-list/src/main.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//! # [TeaTui] List example
|
||||
|
||||
use crate::model::Model;
|
||||
use message::Message;
|
||||
use teatui::ProgramError;
|
||||
|
||||
mod message;
|
||||
mod model;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
fn main() -> Result<(), ProgramError<Model, Message, ()>> {
|
||||
teatui::start(init, update::update, view::view, |_, _| None)
|
||||
}
|
||||
|
||||
fn init() -> (Model, Option<()>) {
|
||||
(Model::default(), None)
|
||||
}
|
||||
75
examples/todo-list/src/message.rs
Normal file
75
examples/todo-list/src/message.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Message {
|
||||
NoOp,
|
||||
Exit,
|
||||
SelectNext,
|
||||
SelectNone,
|
||||
SelectPrevious,
|
||||
SelectFirst,
|
||||
SelectLast,
|
||||
ToggleStatus,
|
||||
}
|
||||
|
||||
impl From<crossterm::event::Event> for Message {
|
||||
fn from(value: crossterm::event::Event) -> Self {
|
||||
match value {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Esc | KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::Exit,
|
||||
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('l') | KeyCode::Char(' ') | KeyCode::Right,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::ToggleStatus,
|
||||
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('h') | KeyCode::Left,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::SelectNone,
|
||||
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('j') | KeyCode::Down,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::SelectNext,
|
||||
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('k') | KeyCode::Up,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::SelectPrevious,
|
||||
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('g') | KeyCode::Home,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::SelectFirst,
|
||||
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('G') | KeyCode::End,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
modifiers: _,
|
||||
}) => Self::SelectLast,
|
||||
|
||||
Event::FocusGained
|
||||
| Event::FocusLost
|
||||
| Event::Key(_)
|
||||
| Event::Mouse(_)
|
||||
| Event::Paste(_)
|
||||
| Event::Resize(_, _) => Self::NoOp,
|
||||
}
|
||||
}
|
||||
}
|
||||
364
examples/todo-list/src/model.rs
Normal file
364
examples/todo-list/src/model.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
use ratatui::widgets::ListState;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Model {
|
||||
pub items: Vec<TodoItem>,
|
||||
pub state: ListState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TodoItem {
|
||||
pub todo: String,
|
||||
pub info: String,
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Status {
|
||||
Todo,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl Default for Model {
|
||||
fn default() -> Self {
|
||||
Model::from_iter([
|
||||
(
|
||||
Status::Todo,
|
||||
"Rewrite everything with Rust!",
|
||||
"I can't hold my inner voice. He tells me to rewrite the complete universe with Rust",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Rewrite all of your tui apps with Ratatui",
|
||||
"Yes, you heard that right. Go and replace your tui with Ratatui.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Pet your cat",
|
||||
"Minnak loves to be pet by you! Don't forget to pet and give some treats!",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Walk with your dog",
|
||||
"Max is bored, go walk with him!",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Pay the bills",
|
||||
"Pay the train subscription!!!",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Refactor list example",
|
||||
"If you see this info that means I completed this task!",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Implement a neural network in pure Rust",
|
||||
"Because why use Python when you can have zero-cost abstractions and borrow checker headaches?",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Buy more coffee",
|
||||
"The compile times are getting longer, or maybe I'm just getting slower. Either way, caffeine is required.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Fix that one annoying lifetime error",
|
||||
"It only took three days and a sacrifice to the crab god, but it finally compiles!",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Clean the mechanical keyboard",
|
||||
"There is a non-zero amount of crumbs between the blue switches. It’s affecting my WPM.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Read the 'Rustonomicon'",
|
||||
"Into the dark depths of unsafe Rust we go. May the pointer aliasing rules have mercy.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Hydrate",
|
||||
"Drank a full glass of water. Look at me, practicing self-care while writing low-level code.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Argue about memory safety on the internet",
|
||||
"Someone said C is 'fine.' I must cordially explain why they are mistaken.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Organize the .dotfiles",
|
||||
"Spend 4 hours configuring Neovim instead of actually working on the project.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Update dependencies",
|
||||
"Ran 'cargo update'. Only 14 breaking changes in the ecosystem today. A new record!",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Research async-trait internals",
|
||||
"Why is it a macro? Why is life a macro? I need to know.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Go to the gym",
|
||||
"Strong body, strong memory management. Don't let your muscles leak like a C++ string.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Explain ownership to a rubber duck",
|
||||
"The duck didn't get it at first, but after a few squeaks, it finally understood move semantics.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Check for compiler updates",
|
||||
"Is there a new nightly? I need those experimental features for no reason at all.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Debug the deadlocked thread",
|
||||
"It's not a bug, it's just a very dedicated pause in execution.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Star every crate I use on GitHub",
|
||||
"Showing love to the maintainers who keep my 'cargo build' alive.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Write a custom proc-macro",
|
||||
"I want to generate code that generates code. We need to go deeper.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Finally learn how Pin works",
|
||||
"I've read the docs five times. Maybe the sixth time is the charm?",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Delete the 'node_modules' folder",
|
||||
"Regained 40GB of disk space. Nature is healing.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Optimize the Docker image size",
|
||||
"From 1.2GB to 10MB using multi-stage builds and Alpine. Efficiency feels good.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Fix the CI/CD pipeline",
|
||||
"It works on my machine, but GitHub Actions thinks otherwise.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Replace a 5-line bash script with 200 lines of Rust",
|
||||
"Was it worth it? Yes. It's type-safe now.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Actually write documentation",
|
||||
"The code is the documentation... said no one ever (and lived).",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Try a different terminal emulator",
|
||||
"Is Alacritty faster than Kitty? Let the benchmarking begin.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Nap for 20 minutes",
|
||||
"Brain was stuck in a recursion loop. Had to reboot the system.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Configure a status bar for the TWM",
|
||||
"I need to see my CPU temperature in 16-bit color at all times.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Rewrite the parser using Nom",
|
||||
"Regex is great, but combinators are cooler.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Find a cool Ferris sticker",
|
||||
"My laptop lid still has 2 square inches of empty space.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Master Vim motions",
|
||||
"H, J, K, L... why did I just delete the entire main function?",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Experiment with WebAssembly",
|
||||
"Rust in the browser is the future, and the future is now.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Add a dark mode to the UI",
|
||||
"My eyes are no longer burning. Success.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Submit a PR to an open-source project",
|
||||
"Fixing a typo counts as a contribution, right?",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Explain Cow to a non-programmer",
|
||||
"No, not the animal. It's 'Clone On Write'. Why are you walking away?",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Organize the physical desk",
|
||||
"Found a missing USB drive and three half-empty coffee mugs.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Write unit tests for the edge cases",
|
||||
"What happens if the input is an emoji and a null byte? Let's find out.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Learn a new functional programming language",
|
||||
"Just to see how it feels. Don't worry Rust, I'm not leaving you.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Solve a LeetCode hard in Rust",
|
||||
"The borrow checker was the real final boss.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Set up a Raspberry Pi server",
|
||||
"It will sit in the corner and run a Telegram bot for two days before I forget about it.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Refactor the error handling",
|
||||
"Switching from 'unwrap()' to 'thiserror' because I'm a professional now.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Explain the orphan rule to a coworker",
|
||||
"I think I confused them more, but I feel smarter.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Benchmark the hot path",
|
||||
"Flamegraphs are like modern art, but for nerds.",
|
||||
),
|
||||
(
|
||||
Status::Todo,
|
||||
"Buy a more ergonomic chair",
|
||||
"My back is screaming in a language I don't understand.",
|
||||
),
|
||||
(
|
||||
Status::Completed,
|
||||
"Finally finish this list",
|
||||
"36 items in total. Now I just have to actually do them.",
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(Status, &'static str, &'static str)> for Model {
|
||||
fn from_iter<I: IntoIterator<Item = (Status, &'static str, &'static str)>>(iter: I) -> Self {
|
||||
let items: Vec<_> = iter
|
||||
.into_iter()
|
||||
.map(|(status, todo, info)| TodoItem::new(status, todo, info))
|
||||
.collect();
|
||||
|
||||
let state = ListState::default().with_selected(Some(0));
|
||||
|
||||
Self { items, state }
|
||||
}
|
||||
}
|
||||
|
||||
impl TodoItem {
|
||||
fn new(status: Status, todo: &str, info: &str) -> Self {
|
||||
Self {
|
||||
status,
|
||||
todo: todo.to_string(),
|
||||
info: info.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn selected(&self) -> Option<&TodoItem> {
|
||||
self.state
|
||||
.selected()
|
||||
.and_then(|index| self.items.get(index))
|
||||
}
|
||||
|
||||
pub fn select_none(self) -> Self {
|
||||
let state = ListState::default().with_selected(None);
|
||||
|
||||
Self {
|
||||
items: self.items,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_first(self) -> Self {
|
||||
let state = ListState::default().with_selected(Some(0));
|
||||
|
||||
Model {
|
||||
items: self.items,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_last(self) -> Self {
|
||||
let state = ListState::default().with_selected(Some(self.items.len() - 1));
|
||||
|
||||
Model {
|
||||
items: self.items,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next(self) -> Self {
|
||||
match self.state.selected() {
|
||||
Some(index) => {
|
||||
let state =
|
||||
ListState::default().with_selected(Some((index + 1) % self.items.len()));
|
||||
|
||||
Model { state, ..self }
|
||||
}
|
||||
None => self.select_first(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_previous(self) -> Self {
|
||||
match self.state.selected() {
|
||||
Some(0) => self.select_last(),
|
||||
Some(index) => {
|
||||
let state = ListState::default().with_selected(Some(index.saturating_sub(1)));
|
||||
Model { state, ..self }
|
||||
}
|
||||
None => self.select_last(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_status(mut self) -> Self {
|
||||
let selected_index = self.state.selected();
|
||||
|
||||
match selected_index {
|
||||
Some(index) => {
|
||||
self.items[index].status = match self.items[index].status {
|
||||
Status::Todo => Status::Completed,
|
||||
Status::Completed => Status::Todo,
|
||||
};
|
||||
|
||||
Model { ..self }
|
||||
}
|
||||
None => Model { ..self },
|
||||
}
|
||||
}
|
||||
}
|
||||
16
examples/todo-list/src/update.rs
Normal file
16
examples/todo-list/src/update.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
use teatui::update::Update;
|
||||
|
||||
use crate::{message::Message, model::Model};
|
||||
|
||||
pub fn update(model: Model, msg: Message) -> Update<Model, ()> {
|
||||
match msg {
|
||||
Message::NoOp => Update::Next(model, None),
|
||||
Message::Exit => Update::Exit,
|
||||
Message::SelectNext => Update::Next(model.select_next(), None),
|
||||
Message::SelectNone => Update::Next(model.select_none(), None),
|
||||
Message::SelectPrevious => Update::Next(model.select_previous(), None),
|
||||
Message::SelectFirst => Update::Next(model.select_first(), None),
|
||||
Message::SelectLast => Update::Next(model.select_last(), None),
|
||||
Message::ToggleStatus => Update::Next(model.toggle_status(), None),
|
||||
}
|
||||
}
|
||||
140
examples/todo-list/src/view.rs
Normal file
140
examples/todo-list/src/view.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{
|
||||
Color, Modifier, Style, Stylize,
|
||||
palette::tailwind::{BLUE, GREEN, SLATE},
|
||||
},
|
||||
symbols,
|
||||
text::Line,
|
||||
widgets::{
|
||||
Block, Borders, HighlightSpacing, List, ListItem, Padding, Paragraph, StatefulWidget,
|
||||
Widget, Wrap,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::model::{Model, Status, TodoItem};
|
||||
|
||||
const TODO_HEADER_STYLE: Style = Style::new().fg(SLATE.c100).bg(BLUE.c800);
|
||||
const NORMAL_ROW_BG: Color = SLATE.c950;
|
||||
const ALT_ROW_BG_COLOR: Color = SLATE.c900;
|
||||
const SELECTED_STYLE: Style = Style::new().bg(SLATE.c800).add_modifier(Modifier::BOLD);
|
||||
const TEXT_FG_COLOR: Color = SLATE.c200;
|
||||
const COMPLETED_TEXT_FG_COLOR: Color = GREEN.c500;
|
||||
|
||||
pub fn view(model: Model) -> AppWidget {
|
||||
AppWidget { model }
|
||||
}
|
||||
|
||||
pub struct AppWidget {
|
||||
model: Model,
|
||||
}
|
||||
|
||||
impl Widget for AppWidget {
|
||||
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||
let [header_area, main_area, footer_area] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let [list_area, item_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]).areas(main_area);
|
||||
|
||||
Paragraph::new("Ratatui List Example")
|
||||
.bold()
|
||||
.centered()
|
||||
.render(header_area, buf);
|
||||
|
||||
Paragraph::new("Use ↓↑ to move, ← to unselect, → to change status, g/G to go top/bottom.")
|
||||
.centered()
|
||||
.render(footer_area, buf);
|
||||
|
||||
self.render_list(list_area, buf);
|
||||
self.render_selected_item(item_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendering logic for the app
|
||||
impl AppWidget {
|
||||
fn render_list(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let block = Block::new()
|
||||
.title(Line::raw("TODO List").centered())
|
||||
.borders(Borders::TOP)
|
||||
.border_set(symbols::border::EMPTY)
|
||||
.border_style(TODO_HEADER_STYLE)
|
||||
.bg(NORMAL_ROW_BG);
|
||||
|
||||
// Iterate through all elements in the `items` and stylize them.
|
||||
let items: Vec<ListItem> = self
|
||||
.model
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, todo_item)| {
|
||||
let color = alternate_colors(i);
|
||||
ListItem::from(todo_item).bg(color)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create a List from all list items and highlight the currently selected one
|
||||
let list = List::new(items)
|
||||
.block(block)
|
||||
.highlight_style(SELECTED_STYLE)
|
||||
.highlight_symbol(">")
|
||||
.highlight_spacing(HighlightSpacing::Always);
|
||||
|
||||
// We need to disambiguate this trait method as both `Widget` and `StatefulWidget` share the
|
||||
// same method name `render`.
|
||||
StatefulWidget::render(list, area, buf, &mut self.model.state);
|
||||
}
|
||||
|
||||
fn render_selected_item(&self, area: Rect, buf: &mut Buffer) {
|
||||
// We get the info depending on the item's state.
|
||||
let info = if let Some(item) = self.model.selected() {
|
||||
match item.status {
|
||||
Status::Completed => format!("✓ DONE: {}", item.info),
|
||||
Status::Todo => format!("☐ TODO: {}", item.info),
|
||||
}
|
||||
} else {
|
||||
"Nothing selected...".to_string()
|
||||
};
|
||||
|
||||
// We show the list item's info under the list in this paragraph
|
||||
let block = Block::new()
|
||||
.title(Line::raw("TODO Info").centered())
|
||||
.borders(Borders::TOP)
|
||||
.border_set(symbols::border::EMPTY)
|
||||
.border_style(TODO_HEADER_STYLE)
|
||||
.bg(NORMAL_ROW_BG)
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
// We can now render the item info
|
||||
Paragraph::new(info)
|
||||
.block(block)
|
||||
.fg(TEXT_FG_COLOR)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
const fn alternate_colors(i: usize) -> Color {
|
||||
if i.is_multiple_of(2) {
|
||||
NORMAL_ROW_BG
|
||||
} else {
|
||||
ALT_ROW_BG_COLOR
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&TodoItem> for ListItem<'_> {
|
||||
fn from(value: &TodoItem) -> Self {
|
||||
let line = match value.status {
|
||||
Status::Todo => Line::styled(format!(" ☐ {}", value.todo), TEXT_FG_COLOR),
|
||||
Status::Completed => {
|
||||
Line::styled(format!(" ✓ {}", value.todo), COMPLETED_TEXT_FG_COLOR)
|
||||
}
|
||||
};
|
||||
ListItem::new(line)
|
||||
}
|
||||
}
|
||||
14
teatui/CHANGELOG.md
Normal file
14
teatui/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.2](https://codeberg.org/JasterV/teatui/compare/teatui-v0.4.1...teatui-v0.4.2) - 2026-04-23
|
||||
|
||||
### Other
|
||||
|
||||
- update deps
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
[package]
|
||||
name = "teatui"
|
||||
version = "0.1.0"
|
||||
version = "0.4.2"
|
||||
description = "An elm-like abstraction over Ratatui"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
tokio = ["dep:tokio"]
|
||||
|
||||
[dependencies]
|
||||
color-eyre.workspace = true
|
||||
crossterm.workspace = true
|
||||
ratatui.workspace = true
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
|
|
|
|||
|
|
@ -1,24 +1,68 @@
|
|||
//! Actor responsible of processing side effects sent by the update actor.
|
||||
use color_eyre::Result;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::mpsc::{Receiver, SendError, Sender};
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
use std::future::Future;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum EffectsError<M> {
|
||||
#[error("Failed to send message to update process")]
|
||||
MessageSend(#[from] SendError<M>),
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tokio"))]
|
||||
pub(crate) fn run<M, Msg, Eff, F>(
|
||||
effects_fn: F,
|
||||
rx: Receiver<(M, Eff)>,
|
||||
tx: Sender<Msg>,
|
||||
) -> Result<()>
|
||||
) -> Result<(), EffectsError<Msg>>
|
||||
where
|
||||
Msg: Send + Sync + 'static,
|
||||
F: Fn(&M, Eff) -> Result<Option<Msg>>,
|
||||
F: Fn(M, Eff) -> Option<Msg>,
|
||||
{
|
||||
loop {
|
||||
let Ok((model, effect)) = rx.recv() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(msg) = effects_fn(&model, effect)? {
|
||||
if let Some(msg) = effects_fn(model, effect) {
|
||||
tx.send(msg)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub(crate) fn run_async<M, Msg, Eff, F, Fut>(
|
||||
effects_fn: F,
|
||||
rx: Receiver<(M, Eff)>,
|
||||
tx: Sender<Msg>,
|
||||
) -> Result<(), EffectsError<Msg>>
|
||||
where
|
||||
M: Send + Sync + 'static,
|
||||
Msg: Send + Sync + 'static,
|
||||
Eff: Send + Sync + 'static,
|
||||
Fut: Future<Output = Option<Msg>> + Send,
|
||||
F: Fn(M, Eff) -> Fut + Send + Sync + 'static,
|
||||
{
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to build Tokio reactor for side-effects");
|
||||
|
||||
rt.block_on(async {
|
||||
loop {
|
||||
let Ok((model, effect)) = rx.recv() else {
|
||||
break;
|
||||
};
|
||||
|
||||
// We spawn the effect in the tokio reactor so they can run concurrently
|
||||
let fut = effects_fn(model, effect);
|
||||
|
||||
if let Some(msg) = fut.await {
|
||||
let _ = tx.send(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
//! Actor responsible of reading terminal input events.
|
||||
use color_eyre::Result;
|
||||
use crossterm::event;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::mpsc::{SendError, Sender};
|
||||
|
||||
pub(crate) fn run<M>(tx: Sender<M>) -> Result<()>
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum EventLoopError<M> {
|
||||
#[error("Failed to send message to update process")]
|
||||
MessageSend(#[from] SendError<M>),
|
||||
#[error("Failed to read crossterm event")]
|
||||
EventRead(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub(crate) fn run<M>(tx: Sender<M>) -> Result<(), EventLoopError<M>>
|
||||
where
|
||||
M: From<crossterm::event::Event> + Sync + Send + 'static,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,20 +31,35 @@
|
|||
//! ### Examples
|
||||
//!
|
||||
//! You can find a folder with example projects in the [examples](https://github.com/JasterV/teatui/tree/main/examples) folder.
|
||||
use color_eyre::Report;
|
||||
use color_eyre::Result;
|
||||
use std::{
|
||||
sync::mpsc::{Sender, channel},
|
||||
thread,
|
||||
};
|
||||
use effects::EffectsError;
|
||||
use events::EventLoopError;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::fmt::Debug;
|
||||
use std::{sync::mpsc::channel, thread};
|
||||
use update::{Update, UpdateError};
|
||||
use view::ViewError;
|
||||
|
||||
pub use update::Update;
|
||||
pub use view::View;
|
||||
pub mod effects;
|
||||
pub mod events;
|
||||
pub mod update;
|
||||
pub mod view;
|
||||
|
||||
mod effects;
|
||||
mod events;
|
||||
mod update;
|
||||
mod view;
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ProgramError<M, Msg, Eff>
|
||||
where
|
||||
Eff: Send + Sync + 'static,
|
||||
{
|
||||
#[error("The update process crashed: '{0}'")]
|
||||
UpdateError(UpdateError<M, Eff>),
|
||||
#[error("The effects process crashed: '{0}'")]
|
||||
EffectsError(EffectsError<Msg>),
|
||||
#[error("The view process crashed: '{0}'")]
|
||||
ViewError(ViewError),
|
||||
#[error("The event loop error crashed: '{0}'")]
|
||||
EventLoopError(EventLoopError<Msg>),
|
||||
#[error("Couldn't gracefully shutdown the program")]
|
||||
GracefulShutdownError,
|
||||
}
|
||||
|
||||
/// Starts the runtime which manages all the internal
|
||||
/// processes and message passing.
|
||||
|
|
@ -58,67 +73,130 @@ mod view;
|
|||
/// - A `view` function, responsible for constructing the view from the model.
|
||||
///
|
||||
/// - An `effects` function responsible for handling side effects.
|
||||
pub fn start<M, Msg, Eff, UF, VF, EF>(
|
||||
model: M,
|
||||
#[cfg(not(feature = "tokio"))]
|
||||
pub fn start<M, Msg, Eff, W, IF, UF, VF, EF>(
|
||||
init_fn: IF,
|
||||
update_fn: UF,
|
||||
view_fn: VF,
|
||||
effects_fn: EF,
|
||||
) -> Result<(), Report>
|
||||
) -> Result<(), ProgramError<M, Msg, Eff>>
|
||||
where
|
||||
M: Clone + Send + Sync + 'static,
|
||||
Eff: Send + Sync + 'static,
|
||||
Eff: Debug + Send + Sync + 'static,
|
||||
Msg: From<crossterm::event::Event> + Sync + Send + 'static,
|
||||
UF: Fn(M, Msg) -> Result<Update<M, Eff>> + Send + Sync + 'static,
|
||||
VF: Fn(&M) -> Result<View> + Send + Sync + 'static,
|
||||
EF: Fn(&M, Eff) -> Result<Option<Msg>> + Send + Sync + 'static,
|
||||
W: Widget,
|
||||
IF: Fn() -> (M, Option<Eff>) + Send + Sync + 'static,
|
||||
UF: Fn(M, Msg) -> Update<M, Eff> + Send + Sync + 'static,
|
||||
VF: Fn(M) -> W + Send + Sync + 'static,
|
||||
EF: Fn(M, Eff) -> Option<Msg> + Send + Sync + 'static,
|
||||
{
|
||||
run_program(init_fn, update_fn, view_fn, move |effects_rx, update_tx| {
|
||||
effects::run(effects_fn, effects_rx, update_tx)
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts the runtime with asynchronous (Tokio) side effects.
|
||||
#[cfg(feature = "tokio")]
|
||||
pub fn start<M, Msg, Eff, W, IF, UF, VF, EF, Fut>(
|
||||
init_fn: IF,
|
||||
update_fn: UF,
|
||||
view_fn: VF,
|
||||
effects_fn: EF,
|
||||
) -> Result<(), ProgramError<M, Msg, Eff>>
|
||||
where
|
||||
M: Clone + Send + Sync + 'static,
|
||||
Eff: Debug + Send + Sync + 'static,
|
||||
Msg: From<crossterm::event::Event> + Sync + Send + 'static,
|
||||
W: Widget,
|
||||
IF: Fn() -> (M, Option<Eff>) + Send + Sync + 'static,
|
||||
UF: Fn(M, Msg) -> Update<M, Eff> + Send + Sync + 'static,
|
||||
VF: Fn(M) -> W + Send + Sync + 'static,
|
||||
EF: Fn(M, Eff) -> Fut + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = Option<Msg>> + Send,
|
||||
{
|
||||
run_program(init_fn, update_fn, view_fn, move |effects_rx, update_tx| {
|
||||
effects::run_async(effects_fn, effects_rx, update_tx)
|
||||
})
|
||||
}
|
||||
|
||||
/// Internal helper to abstract the common actor-spawning logic.
|
||||
fn run_program<M, Msg, Eff, W, IF, UF, VF, SF>(
|
||||
init_fn: IF,
|
||||
update_fn: UF,
|
||||
view_fn: VF,
|
||||
effects_fn: SF,
|
||||
) -> Result<(), ProgramError<M, Msg, Eff>>
|
||||
where
|
||||
M: Clone + Send + Sync + 'static,
|
||||
Eff: Debug + Send + Sync + 'static,
|
||||
Msg: From<crossterm::event::Event> + Sync + Send + 'static,
|
||||
W: Widget,
|
||||
IF: Fn() -> (M, Option<Eff>) + Send + Sync + 'static,
|
||||
UF: Fn(M, Msg) -> Update<M, Eff> + Send + Sync + 'static,
|
||||
VF: Fn(M) -> W + Send + Sync + 'static,
|
||||
SF: FnOnce(
|
||||
std::sync::mpsc::Receiver<(M, Eff)>,
|
||||
std::sync::mpsc::Sender<Msg>,
|
||||
) -> Result<(), EffectsError<Msg>>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
{
|
||||
let terminal = ratatui::init();
|
||||
|
||||
// Channel for signaling when a task completes
|
||||
let (shutdown_tx, shutdown_rx) = channel::<Result<()>>();
|
||||
|
||||
// Channels for inter-thread communication
|
||||
let (shutdown_tx, shutdown_rx) = channel::<Result<(), ProgramError<M, Msg, Eff>>>();
|
||||
let (update_tx, update_rx) = channel::<Msg>();
|
||||
let (view_tx, view_rx) = channel::<M>();
|
||||
let (effects_tx, effects_rx) = channel::<(M, Eff)>();
|
||||
|
||||
// Spawn order is important.
|
||||
// If the view actor is started after the update actor, it could happen
|
||||
// that both actors have an out of sync version of the model for a bit.
|
||||
//
|
||||
let model_1 = model.clone();
|
||||
spawn_thread(
|
||||
|| view::run(model_1, terminal, view_fn, view_rx),
|
||||
shutdown_tx.clone(),
|
||||
);
|
||||
// Spawn View Actor
|
||||
thread::spawn({
|
||||
let (model, _) = init_fn();
|
||||
let shutdown_tx = shutdown_tx.clone();
|
||||
move || {
|
||||
let result =
|
||||
view::run(model, terminal, view_fn, view_rx).map_err(ProgramError::ViewError);
|
||||
let _ = shutdown_tx.send(result);
|
||||
}
|
||||
});
|
||||
|
||||
spawn_thread(
|
||||
|| update::run(model, update_fn, update_rx, view_tx, effects_tx),
|
||||
shutdown_tx.clone(),
|
||||
);
|
||||
// Spawn Update Actor
|
||||
thread::spawn({
|
||||
let shutdown_tx = shutdown_tx.clone();
|
||||
let (model, effect) = init_fn();
|
||||
move || {
|
||||
let result = update::run(model, effect, update_fn, update_rx, view_tx, effects_tx)
|
||||
.map_err(ProgramError::UpdateError);
|
||||
let _ = shutdown_tx.send(result);
|
||||
}
|
||||
});
|
||||
|
||||
let effects_update_tx = update_tx.clone();
|
||||
spawn_thread(
|
||||
|| effects::run(effects_fn, effects_rx, effects_update_tx),
|
||||
shutdown_tx.clone(),
|
||||
);
|
||||
// Spawn Effects Actor
|
||||
thread::spawn({
|
||||
let shutdown_tx = shutdown_tx.clone();
|
||||
let update_tx = update_tx.clone();
|
||||
|
||||
spawn_thread(|| events::run(update_tx), shutdown_tx.clone());
|
||||
move || {
|
||||
let result = effects_fn(effects_rx, update_tx).map_err(ProgramError::EffectsError);
|
||||
|
||||
let result = shutdown_rx.recv();
|
||||
let _ = shutdown_tx.send(result);
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn Events Actor
|
||||
thread::spawn({
|
||||
let shutdown_tx = shutdown_tx.clone();
|
||||
move || {
|
||||
let result = events::run(update_tx).map_err(ProgramError::EventLoopError);
|
||||
let _ = shutdown_tx.send(result);
|
||||
}
|
||||
});
|
||||
|
||||
let result = shutdown_rx.recv().ok();
|
||||
ratatui::restore();
|
||||
|
||||
result?
|
||||
}
|
||||
|
||||
fn spawn_thread<F>(callback: F, shutdown: Sender<Result<()>>) -> thread::JoinHandle<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
F: Send + 'static,
|
||||
{
|
||||
thread::spawn(move || {
|
||||
let result = callback();
|
||||
let _ = shutdown.send(result);
|
||||
})
|
||||
match result {
|
||||
Some(result) => result,
|
||||
None => Err(ProgramError::GracefulShutdownError),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,54 @@
|
|||
//! Actor responsible of maintaining the state of the application.
|
||||
use color_eyre::{Report, Result};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::mpsc::{Receiver, SendError, Sender};
|
||||
|
||||
/// Tells the runtime what to do with the previous message.
|
||||
///
|
||||
/// If `Update::Exit` is returned, the program will exit.
|
||||
///
|
||||
/// If `Update::Next(M)` is returned, the view will be rendered with the new model.
|
||||
///
|
||||
/// If `Update::NextWithEffect` is returned, the view will be rendered with the new model and a side effect will be executed.
|
||||
/// If `Update::Next(M, Option<E>)` is returned, the view will be rendered with the new model and a side effect might be executed.
|
||||
pub enum Update<M, E> {
|
||||
Exit,
|
||||
Next(M),
|
||||
NextWithEffect(M, E),
|
||||
Next(M, Option<E>),
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum UpdateError<M, Eff>
|
||||
where
|
||||
Eff: Send + Sync + 'static,
|
||||
{
|
||||
#[error("Failed to send message to effects handler process")]
|
||||
EffectSend(#[from] SendError<(M, Eff)>),
|
||||
#[error("Failed to send message to the view process")]
|
||||
ViewSend(#[from] SendError<M>),
|
||||
}
|
||||
|
||||
pub(crate) fn run<M, Msg, Eff, F>(
|
||||
mut model: M,
|
||||
initial_effect: Option<Eff>,
|
||||
update_fn: F,
|
||||
rx: Receiver<Msg>,
|
||||
view_tx: Sender<M>,
|
||||
effects_tx: Sender<(M, Eff)>,
|
||||
) -> Result<()>
|
||||
) -> Result<(), UpdateError<M, Eff>>
|
||||
where
|
||||
F: Fn(M, Msg) -> Result<Update<M, Eff>, Report>,
|
||||
F: Fn(M, Msg) -> Update<M, Eff>,
|
||||
Eff: Sync + Send + 'static,
|
||||
M: Clone + Sync + Send + 'static,
|
||||
{
|
||||
if let Some(effect) = initial_effect {
|
||||
effects_tx.send((model.clone(), effect))?;
|
||||
}
|
||||
|
||||
loop {
|
||||
let Ok(msg) = rx.recv() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let update = update_fn(model, msg)?;
|
||||
let update = update_fn(model, msg);
|
||||
|
||||
let (new_model, effect) = match update {
|
||||
Update::Exit => return Ok(()),
|
||||
Update::Next(new_model) => (new_model, None),
|
||||
Update::NextWithEffect(new_model, effect) => (new_model, Some(effect)),
|
||||
Update::Next(new_model, effect) => (new_model, effect),
|
||||
};
|
||||
|
||||
// Send the new model to the view
|
||||
|
|
|
|||
|
|
@ -1,38 +1,26 @@
|
|||
//! Actor responsible of rendering the model into the terminal.
|
||||
use color_eyre::Result;
|
||||
use ratatui::DefaultTerminal;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
/// A thin wrapper around a `ratatui` WidgetRef.
|
||||
/// It is guaranteed that it will always be possible
|
||||
/// to construct it from a Widget.
|
||||
pub struct View(Box<dyn WidgetRef>);
|
||||
|
||||
impl View {
|
||||
pub fn new(widget: impl WidgetRef + 'static) -> Self {
|
||||
Self(Box::new(widget))
|
||||
}
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ViewError {
|
||||
#[error("Failed to render a widget into the terminal")]
|
||||
RenderError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl Widget for View {
|
||||
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) {
|
||||
self.0.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run<M, F>(
|
||||
pub(crate) fn run<M, F, W>(
|
||||
mut model: M,
|
||||
mut terminal: DefaultTerminal,
|
||||
view_fn: F,
|
||||
rx: Receiver<M>,
|
||||
) -> Result<()>
|
||||
) -> Result<(), ViewError>
|
||||
where
|
||||
F: Fn(&M) -> Result<View>,
|
||||
W: Widget,
|
||||
F: Fn(M) -> W,
|
||||
{
|
||||
loop {
|
||||
let widget = view_fn(&model)?;
|
||||
let widget = view_fn(model);
|
||||
|
||||
terminal.draw(|frame| frame.render_widget(widget, frame.area()))?;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue