Compare commits

...

15 commits
0.3.0 ... main

Author SHA1 Message Date
Victor Martinez Montané
26b9b8921f chore: release-plz update (#1)
This is an automated PR generated by [release-plz](https://github.com/MarcoIeni/release-plz) via Woodpecker CI.

Co-authored-by: release-plz-bot <bot@codeberg.org>
Reviewed-on: https://codeberg.org/JasterV/teatui/pulls/1
2026-04-24 14:33:33 +02:00
JasterV
39e0614412 chore: update deps 2026-04-23 09:57:10 +02:00
JasterV
2144832c22 fix: CD steps order 2026-03-07 01:05:56 +01:00
JasterV
dc920ba83c chore: update Cargo.toml info 2026-03-07 01:01:13 +01:00
JasterV
5ceee74b98 chore: use codeberg container registry images 2026-03-07 00:52:11 +01:00
JasterV
badcee4e35 fix CD 2026-03-07 00:40:15 +01:00
JasterV
7996d4e497 add deny.toml 2026-03-07 00:11:18 +01:00
JasterV
5b82c75480 chore: Add Makefile 2026-03-07 00:11:18 +01:00
JasterV
52f6f0f36f feat: migrate to woodpecker 2026-03-07 00:11:18 +01:00
dependabot[bot]
5e31cb2654
build(deps): bump actions/checkout from 5 to 6 (#6)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 17:42:16 +01:00
JasterV
4d7ba57f96 feat: add new example for working with lists 2026-01-30 13:09:29 +01:00
JasterV
92118d929e feat: add a new example for working with tabs 2026-01-30 02:21:03 +01:00
JasterV
c24d7762cb update CI 2026-01-29 15:17:40 +01:00
JasterV
29a16f368c release: 0.4.0 2026-01-29 15:15:40 +01:00
JasterV
f32c6e9dd9 feat: provide a new tokio feature for async effects support via tokio 2026-01-29 15:02:14 +01:00
24 changed files with 1582 additions and 292 deletions

View file

@ -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

View file

@ -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@v5
- 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

View file

@ -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@v5
- 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
View 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
View 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

564
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,12 +3,12 @@ resolver = "3"
members = ["teatui", "examples/*"]
[workspace.package]
authors = ["JasterV <49537445+JasterV@users.noreply.github.com>"]
authors = ["JasterV jasterv@noreply.codeberg.org"]
description = "A pure functional framework to build TUIs build on top of ratatui"
edition = "2024"
homepage = "https://github.com/JasterV/teatui"
homepage = "https://codeberg.org/JasterV/teatui"
license = "MIT"
repository = "https://github.com/JasterV/teatui"
repository = "https://codeberg.org/JasterV/teatui"
[workspace.dependencies]
crossterm = "0.29.0"

47
Makefile.toml Normal file
View 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
View 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 = []

View file

@ -4,6 +4,7 @@ publish = false
version = "0.0.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
crossterm.workspace = true

View file

@ -101,14 +101,14 @@ pub fn update(model: Model, msg: Message) -> Update<Model, Effect> {
}
}
pub fn run_effects(_model: &Model, _effect: Effect) -> Option<Message> {
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) -> Paragraph<'static> {
pub fn view(model: Model) -> Paragraph<'static> {
let counter = model.counter;
let title = Line::from("Ratatui Actor-based Counter")

13
examples/tabs/Cargo.toml Normal file
View 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
View 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,
}
}
}

View 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" }

View 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)
}

View 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,
}
}
}

View 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. Its 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 },
}
}
}

View 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),
}
}

View 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
View 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

View file

@ -1,12 +1,17 @@
[package]
name = "teatui"
version = "0.2.1"
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]
crossterm.workspace = true
ratatui.workspace = true
thiserror = "2"
tokio = { version = "1", features = ["full"], optional = true }

View file

@ -1,12 +1,16 @@
//! Actor responsible of processing side effects sent by the update actor.
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)>,
@ -14,15 +18,51 @@ pub(crate) fn run<M, Msg, Eff, F>(
) -> Result<(), EffectsError<Msg>>
where
Msg: Send + Sync + 'static,
F: Fn(&M, Eff) -> 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(())
}

View file

@ -73,6 +73,7 @@ where
/// - A `view` function, responsible for constructing the view from the model.
///
/// - An `effects` function responsible for handling side effects.
#[cfg(not(feature = "tokio"))]
pub fn start<M, Msg, Eff, W, IF, UF, VF, EF>(
init_fn: IF,
update_fn: UF,
@ -86,69 +87,112 @@ where
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,
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<(), ProgramError<M, Msg, Eff>>>();
// Channels for inter-thread communication
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.
// 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(|err| ProgramError::ViewError(err));
let result =
view::run(model, terminal, view_fn, view_rx).map_err(ProgramError::ViewError);
let _ = shutdown_tx.send(result);
}
});
// 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(|err| ProgramError::UpdateError(err));
.map_err(ProgramError::UpdateError);
let _ = shutdown_tx.send(result);
}
});
// Spawn Effects Actor
thread::spawn({
let shutdown_tx = shutdown_tx.clone();
let update_tx = update_tx.clone();
let shutdown_tx = shutdown_tx.clone();
move || {
let result = effects::run(effects_fn, effects_rx, update_tx)
.map_err(|err| ProgramError::EffectsError(err));
let result = effects_fn(effects_rx, update_tx).map_err(ProgramError::EffectsError);
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(|err| ProgramError::EventLoopError(err));
let result = events::run(update_tx).map_err(ProgramError::EventLoopError);
let _ = shutdown_tx.send(result);
}
});
let result = shutdown_rx.recv().ok();
ratatui::restore();
match result {

View file

@ -17,10 +17,10 @@ pub(crate) fn run<M, F, W>(
) -> Result<(), ViewError>
where
W: Widget,
F: Fn(&M) -> W,
F: Fn(M) -> W,
{
loop {
let widget = view_fn(&model);
let widget = view_fn(model);
terminal.draw(|frame| frame.render_widget(widget, frame.area()))?;