Skip to content

Commit 9b0abb5

Browse files
committed
feat: Add crates-tui tutorial
1 parent b214193 commit 9b0abb5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+5108
-1457
lines changed

Cargo.lock

Lines changed: 799 additions & 174 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

astro.config.mjs

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ import remarkIncludeCode from "/src/plugins/remark-code-import";
1212

1313
// https://astro.build/config
1414
export default defineConfig({
15+
image: {
16+
service: {
17+
entrypoint: "astro/assets/services/sharp",
18+
config: {
19+
limitInputPixels: false,
20+
},
21+
},
22+
},
1523
site: "https://ratatui.rs",
1624
image: {
1725
service: {
@@ -135,21 +143,40 @@ export default defineConfig({
135143
],
136144
},
137145
{
138-
label: "Async Counter App",
146+
label: "Crates TUI App",
139147
collapsed: true,
140148
items: [
141-
{ label: "Async Counter App", link: "/tutorials/counter-async-app/" },
149+
{ label: "Crates TUI", link: "/tutorials/crates-tui/" },
150+
{ label: "Main", link: "/tutorials/crates-tui/main" },
142151
{
143-
label: "Async KeyEvents",
144-
link: "/tutorials/counter-async-app/async-event-stream/",
152+
label: "Helper",
153+
link: "/tutorials/crates-tui/crates-io-api-helper",
145154
},
146-
{ label: "Async Render", link: "/tutorials/counter-async-app/full-async-events/" },
147-
{ label: "Introducing Actions", link: "/tutorials/counter-async-app/actions/" },
155+
{ label: "Tui", link: "/tutorials/crates-tui/tui" },
156+
{ label: "Errors", link: "/tutorials/crates-tui/errors" },
157+
{ label: "Events", link: "/tutorials/crates-tui/events" },
148158
{
149-
label: "Async Actions",
150-
link: "/tutorials/counter-async-app/full-async-actions/",
159+
label: "App",
160+
collapsed: true,
161+
items: [
162+
{ label: "App", link: "/tutorials/crates-tui/app-basics" },
163+
{ label: "App Mode", link: "/tutorials/crates-tui/app-mode" },
164+
{ label: "App Async", link: "/tutorials/crates-tui/app-async" },
165+
{ label: "App Prototype", link: "/tutorials/crates-tui/app-prototype" },
166+
],
167+
},
168+
{
169+
label: "Widgets",
170+
collapsed: true,
171+
items: [
172+
{ label: "Widgets", link: "/tutorials/crates-tui/widgets" },
173+
{ label: "Search", link: "/tutorials/crates-tui/search" },
174+
{ label: "Prompt", link: "/tutorials/crates-tui/prompt" },
175+
{ label: "Results", link: "/tutorials/crates-tui/results" },
176+
{ label: "App", link: "/tutorials/crates-tui/app" },
177+
],
151178
},
152-
{ label: "Conclusion", link: "/tutorials/counter-async-app/conclusion/" },
179+
{ label: "Conclusion", link: "/tutorials/crates-tui/conclusion" },
153180
],
154181
},
155182
],
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.data/*.log
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "crates-tui"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7+
8+
[dependencies]
9+
color-eyre = "0.6.2"
10+
crates_io_api = "0.9.0"
11+
crossterm = { version = "0.27.0", features = ["serde", "event-stream"] }
12+
futures = "0.3.28"
13+
itertools = "0.12.0"
14+
ratatui = { version = "0.26.1", features = ["serde", "macros"] }
15+
tokio = { version = "1.36.0", features = ["full"] }
16+
tokio-stream = "0.1.14"
17+
tui-input = "0.8.0"

code/crates-tui-tutorial-app/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 The Ratatui Developers
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# A VHS tape. See https://github.com/charmbracelet/vhs
2+
Output crates-tui-demo.gif
3+
Set Theme "Aardvark Blue"
4+
Set Width 1600
5+
Set Height 800
6+
Type "cargo run --bin crates-tui"
7+
Enter
8+
Sleep 3s
9+
Type @0.1s "ratatui"
10+
Sleep 1s
11+
Enter
12+
Sleep 5s
13+
Down @0.1s 5
14+
Sleep 5s
15+
Up @0.1s 5
16+
Sleep 5s
17+
Screenshot crates-tui-demo-2.png
18+
Sleep 2s
19+
Type "/"
20+
Sleep 1s
21+
Screenshot crates-tui-demo-1.png
22+
Sleep 2s
23+
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// ANCHOR: imports_all
2+
// ANCHOR: imports_external
3+
use color_eyre::eyre::Result;
4+
use crossterm::event::KeyEvent;
5+
use ratatui::{prelude::*, widgets::Paragraph};
6+
// ANCHOR: imports_external
7+
8+
// ANCHOR: imports_core
9+
use crate::{
10+
events::{Event, Events},
11+
tui::Tui,
12+
widgets::{search_page::SearchPage, search_page::SearchPageWidget},
13+
};
14+
// ANCHOR_END: imports_core
15+
// ANCHOR_END: imports_all
16+
17+
// ANCHOR: full_app
18+
// ANCHOR: mode
19+
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
20+
pub enum Mode {
21+
#[default]
22+
Prompt,
23+
Results,
24+
}
25+
// ANCHOR_END: mode
26+
27+
// ANCHOR: mode_handle_key
28+
impl Mode {
29+
fn handle_key(&self, key: KeyEvent) -> Option<Action> {
30+
use crossterm::event::KeyCode::*;
31+
let action = match self {
32+
Mode::Prompt => match key.code {
33+
Enter => Action::SubmitSearchQuery,
34+
Esc => Action::SwitchMode(Mode::Results),
35+
_ => return None,
36+
},
37+
Mode::Results => match key.code {
38+
Up => Action::ScrollUp,
39+
Down => Action::ScrollDown,
40+
Char('/') => Action::SwitchMode(Mode::Prompt),
41+
Esc => Action::Quit,
42+
_ => return None,
43+
},
44+
};
45+
Some(action)
46+
}
47+
}
48+
// ANCHOR_END: mode_handle_key
49+
50+
// ANCHOR: action
51+
#[derive(Debug, Clone, PartialEq, Eq)]
52+
pub enum Action {
53+
Quit,
54+
SwitchMode(Mode),
55+
ScrollDown,
56+
ScrollUp,
57+
SubmitSearchQuery,
58+
UpdateSearchResults,
59+
}
60+
// ANCHOR_END: action
61+
62+
// ANCHOR: app
63+
#[derive(Debug)]
64+
pub struct App {
65+
quit: bool,
66+
last_key_event: Option<crossterm::event::KeyEvent>,
67+
mode: Mode,
68+
rx: tokio::sync::mpsc::UnboundedReceiver<Action>,
69+
tx: tokio::sync::mpsc::UnboundedSender<Action>,
70+
71+
search_page: SearchPage, // new
72+
}
73+
// ANCHOR_END: app
74+
75+
impl App {
76+
// ANCHOR: app_new
77+
pub fn new() -> Self {
78+
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
79+
let search_page = SearchPage::new(tx.clone());
80+
let mode = Mode::default();
81+
let quit = false;
82+
let last_key_event = None;
83+
Self {
84+
quit,
85+
last_key_event,
86+
mode,
87+
rx,
88+
tx,
89+
search_page,
90+
}
91+
}
92+
// ANCHOR_END: app_new
93+
94+
// ANCHOR: app_run
95+
pub async fn run(
96+
&mut self,
97+
mut tui: Tui,
98+
mut events: Events,
99+
) -> Result<()> {
100+
loop {
101+
if let Some(e) = events.next().await {
102+
if matches!(e, Event::Render) {
103+
self.draw(&mut tui)?
104+
} else {
105+
self.handle_event(e)?
106+
}
107+
}
108+
while let Ok(action) = self.rx.try_recv() {
109+
self.handle_action(action)?;
110+
}
111+
if self.should_quit() {
112+
break;
113+
}
114+
}
115+
Ok(())
116+
}
117+
// ANCHOR_END: app_run
118+
119+
// ANCHOR: app_handle_event
120+
fn handle_event(&mut self, e: Event) -> Result<()> {
121+
use crossterm::event::Event as CrosstermEvent;
122+
if let Event::Crossterm(CrosstermEvent::Key(key)) = e {
123+
self.last_key_event = Some(key);
124+
self.handle_key(key)
125+
};
126+
Ok(())
127+
}
128+
// ANCHOR_END: app_handle_event
129+
130+
// ANCHOR: app_handle_key_event
131+
fn handle_key(&mut self, key: KeyEvent) {
132+
let maybe_action = self.mode.handle_key(key);
133+
if maybe_action.is_none() && matches!(self.mode, Mode::Prompt) {
134+
self.search_page.handle_key(key);
135+
}
136+
maybe_action.map(|action| self.tx.send(action));
137+
}
138+
// ANCHOR_END: app_handle_key_event
139+
140+
// ANCHOR: app_handle_action
141+
fn handle_action(&mut self, action: Action) -> Result<()> {
142+
match action {
143+
Action::Quit => self.quit(),
144+
Action::SwitchMode(mode) => self.switch_mode(mode),
145+
Action::ScrollUp => self.search_page.scroll_up(),
146+
Action::ScrollDown => self.search_page.scroll_down(),
147+
Action::SubmitSearchQuery => self.search_page.submit_query(),
148+
Action::UpdateSearchResults => {
149+
self.search_page.update_search_results()
150+
}
151+
}
152+
Ok(())
153+
}
154+
// ANCHOR_END: app_handle_action
155+
}
156+
157+
impl Default for App {
158+
fn default() -> Self {
159+
Self::new()
160+
}
161+
}
162+
163+
impl App {
164+
// ANCHOR: app_draw
165+
fn draw(&mut self, tui: &mut Tui) -> Result<()> {
166+
tui.draw(|frame| {
167+
frame.render_stateful_widget(AppWidget, frame.size(), self);
168+
self.set_cursor(frame);
169+
})?;
170+
Ok(())
171+
}
172+
// ANCHOR_END: app_draw
173+
174+
fn quit(&mut self) {
175+
self.quit = true
176+
}
177+
178+
fn switch_mode(&mut self, mode: Mode) {
179+
self.mode = mode;
180+
}
181+
182+
fn should_quit(&self) -> bool {
183+
self.quit
184+
}
185+
186+
fn set_cursor(&mut self, frame: &mut Frame<'_>) {
187+
if matches!(self.mode, Mode::Prompt) {
188+
if let Some(cursor_position) = self.search_page.cursor_position() {
189+
frame.set_cursor(cursor_position.x, cursor_position.y)
190+
}
191+
}
192+
}
193+
}
194+
195+
// ANCHOR: app_widget
196+
struct AppWidget;
197+
// ANCHOR_END: app_widget
198+
199+
// ANCHOR: app_statefulwidget
200+
impl StatefulWidget for AppWidget {
201+
type State = App;
202+
203+
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
204+
let [status_bar, search_page] =
205+
Layout::vertical([Constraint::Length(1), Constraint::Fill(0)])
206+
.areas(area);
207+
208+
if let Some(key) = state.last_key_event {
209+
Paragraph::new(format!("last key event: {:?}", key.code))
210+
.right_aligned()
211+
.render(status_bar, buf);
212+
}
213+
214+
if state.search_page.loading() {
215+
Line::from("Loading...").render(status_bar, buf);
216+
}
217+
218+
SearchPageWidget { mode: state.mode }.render(
219+
search_page,
220+
buf,
221+
&mut state.search_page,
222+
);
223+
}
224+
}
225+
// ANCHOR_END: app_statefulwidget
226+
227+
// ANCHOR_END: full_app
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
use crates_tui::app;
2+
use crates_tui::errors;
3+
use crates_tui::events;
4+
use crates_tui::tui;
5+
6+
// ANCHOR: main
7+
#[tokio::main]
8+
async fn main() -> color_eyre::Result<()> {
9+
errors::install_hooks()?;
10+
11+
let tui = tui::init()?;
12+
let events = events::Events::new();
13+
app::App::new().run(tui, events).await?;
14+
tui::restore()?;
15+
16+
Ok(())
17+
}
18+
// ANCHOR_END: main

0 commit comments

Comments
 (0)