1#[cfg(all(
2 not(feature = "crossterm"),
3 not(feature = "termion"),
4 not(feature = "termwiz")
5))]
6compile_error!("The demo needs one of the crossterm, termion, or termwiz features");
7
8#[cfg(feature = "crossterm")]
9mod crossterm;
10#[cfg(feature = "termion")]
11mod termion;
12#[cfg(feature = "termwiz")]
13mod termwiz;
14
15use std::{env, error::Error, num::Wrapping as w, path::PathBuf, sync::Once, time::Duration};
16
17use image::DynamicImage;
18use ratatui::{
19 Frame, Terminal,
20 backend::Backend,
21 layout::{Constraint, Direction, Layout, Rect, Size},
22 style::{Color, Stylize},
23 text::{Line, Span, Text},
24 widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
25};
26use ratatui_image::{
27 Image, Resize, StatefulImage,
28 picker::{Picker, cap_parser::QueryStdioOptions},
29 protocol::{Protocol, StatefulProtocol},
30 sliced::{SignedPosition, SlicedImage, SlicedProtocol},
31};
32
33fn main() -> Result<(), Box<dyn Error>> {
34 #[cfg(feature = "crossterm")]
35 crate::crossterm::run()?;
36 #[cfg(feature = "termion")]
37 crate::termion::run()?;
38 #[cfg(feature = "termwiz")]
39 crate::termwiz::run()?;
40 Ok(())
41}
42
43static READY: Once = Once::new();
44
45#[derive(Debug, PartialEq, Eq)]
46enum ShowImages {
47 All,
48 Fixed,
49 Resized,
50}
51
52struct App {
53 title: String,
54 should_quit: bool,
55 tick_rate: Duration,
56 background: String,
57 split_percent: u16,
58 show_images: ShowImages,
59
60 image_source_path: PathBuf,
61
62 picker: Picker,
63 image_source: DynamicImage,
64 image_static: Protocol,
65 image_fit_state: StatefulProtocol,
66 image_crop_state: StatefulProtocol,
67 image_scale_state: StatefulProtocol,
68 image_sliced: SlicedProtocol,
69 image_sliced_position: (SignedPosition, bool, bool), image_sliced_viewport: Option<Size>,
71}
72
73fn size() -> Size {
74 Size::new(30, 16)
75}
76
77impl App {
78 pub fn new<B: Backend>(_: &mut Terminal<B>) -> Self {
79 let title = format!(
80 "Demo ({})",
81 env::var("TERM").unwrap_or("unknown".to_string())
82 );
83
84 let image = if env::args().any(|arg| arg == "--tmp-demo-ready") {
85 "./assets/Jenkins.png"
86 } else {
87 "./assets/Ada.png"
88 };
89 let image_source = image::ImageReader::open(image).unwrap().decode().unwrap();
90
91 let picker = Picker::from_query_stdio_with_options(QueryStdioOptions {
92 terminal_background_color_osc: true,
95 text_sizing_protocol: true,
96 ..Default::default()
97 })
98 .unwrap();
99
100 let image_static = picker
101 .new_protocol(image_source.clone(), size(), Resize::Fit(None))
102 .expect("demo gets a protocol from image");
103 let image_fit_state = picker.new_resize_protocol(image_source.clone());
104 let image_crop_state = picker.new_resize_protocol(image_source.clone());
105 let image_scale_state = picker.new_resize_protocol(image_source.clone());
106
107 let image_sliced = SlicedProtocol::new(&picker, image_source.clone(), None).unwrap();
108
109 let mut background = String::new();
110
111 let mut r: [u64; 2] = [0x8a5cd789635d2dff, 0x121fd2155c472f96];
112 for _ in 0..5_000 {
113 let mut s1 = w(r[0]);
114 let s0 = w(r[1]);
115 let result = s0 + s1;
116 r[0] = s0.0;
117 s1 ^= s1 << 23;
118 r[1] = (s1 ^ s0 ^ (s1 >> 18) ^ (s0 >> 5)).0;
119 let c = match result.0 % 4 {
120 0 => '.',
121 1 => ' ',
122 _ => '…',
123 };
124 background.push(c);
125 }
126
127 let image_sliced_position = (
128 SignedPosition {
129 x: 0,
130 y: -((image_sliced.size().height / 2) as i16),
131 },
132 true,
133 false,
134 );
135
136 Self {
137 title,
138 should_quit: false,
139 tick_rate: Duration::from_millis(100),
140 background,
141 show_images: ShowImages::All,
142 split_percent: 70,
143 picker,
144 image_source,
145 image_source_path: image.into(),
146
147 image_static,
148 image_fit_state,
149 image_crop_state,
150 image_scale_state,
151 image_sliced,
152 image_sliced_position,
153 image_sliced_viewport: None,
154 }
155 }
156 pub fn on_key(&mut self, c: char) -> bool {
157 match c {
158 'q' => {
159 self.should_quit = true;
160 }
161 't' => {
162 self.show_images = match self.show_images {
163 ShowImages::All => ShowImages::Fixed,
164 ShowImages::Fixed => ShowImages::Resized,
165 ShowImages::Resized => ShowImages::All,
166 }
167 }
168 'i' => {
169 let next = self.picker.protocol_type().next();
176 self.picker.set_protocol_type(next);
177 self.reset_images();
178 }
179 'o' => {
180 let path = match self.image_source_path.to_str() {
181 Some("./assets/Ada.png") => "./assets/Jenkins.png",
182 Some("./assets/Jenkins.png") => "./assets/NixOS.png",
183 _ => "./assets/Ada.png",
184 };
185 self.image_source = image::ImageReader::open(path).unwrap().decode().unwrap();
186 self.image_source_path = path.into();
187 self.reset_images();
188 }
189 'H' => {
190 if self.split_percent >= 10 {
191 self.split_percent -= 10;
192 }
193 }
194 'L' => {
195 if self.split_percent <= 90 {
196 self.split_percent += 10;
197 }
198 }
199 'h' => {
200 let (pos, _, _) = &mut self.image_sliced_position;
201 if pos.x > 0 {
202 pos.x -= 1;
203 }
204 }
205 'j' => {
206 let (pos, _, _) = &mut self.image_sliced_position;
207 if let Some(viewport) = self.image_sliced_viewport
208 && (pos.y < 0 || (pos.y as u16) < viewport.height.saturating_sub(1))
209 {
210 pos.y += 1;
211 }
212 }
213 'k' => {
214 let (pos, _, _) = &mut self.image_sliced_position;
215 if pos.y > 0
216 || pos.y.unsigned_abs() < self.image_sliced.size().height.saturating_sub(1)
217 {
218 pos.y -= 1;
219 }
220 }
221 'l' => {
222 let (pos, _, _) = &mut self.image_sliced_position;
223 if let Some(viewport) = self.image_sliced_viewport
224 && pos.x
225 < (viewport
226 .width
227 .saturating_sub(self.image_sliced.size().width)
228 as i16)
229 {
230 pos.x += 1;
231 }
232 }
233 'a' => {
234 self.image_sliced_position.2 = !self.image_sliced_position.2;
235 }
236 _ => {
237 return false;
238 }
239 }
240 true
241 }
242
243 fn reset_images(&mut self) {
244 self.image_static = self
245 .picker
246 .new_protocol(self.image_source.clone(), size(), Resize::Fit(None))
247 .unwrap();
248 self.image_fit_state = self.picker.new_resize_protocol(self.image_source.clone());
249 self.image_crop_state = self.picker.new_resize_protocol(self.image_source.clone());
250 self.image_scale_state = self.picker.new_resize_protocol(self.image_source.clone());
251 self.image_sliced =
252 SlicedProtocol::new(&self.picker, self.image_source.clone(), None).unwrap();
253 }
254
255 pub fn on_tick(&mut self) -> bool {
256 READY.call_once(|| {
257 if env::args().any(|arg| arg == "--tmp-demo-ready") {
259 if let Err(err) = std::fs::File::create("/tmp/demo-ready") {
260 panic!("{err}");
261 }
262 }
263 });
264
265 if let Some(viewport) = self.image_sliced_viewport {
266 let (pos, is_moving_rightwards, is_animating) = &mut self.image_sliced_position;
267 if *is_animating {
268 pos.y += 1;
269 if pos.y > 0 && pos.y.unsigned_abs() > viewport.height {
270 pos.y = -(self.image_sliced.size().height as i16);
271 }
272
273 if *is_moving_rightwards {
274 if pos.x
275 >= viewport
276 .width
277 .saturating_sub(self.image_sliced.size().width)
278 as i16
279 {
280 pos.x = viewport
281 .width
282 .saturating_sub(self.image_sliced.size().width)
283 as i16;
284 *is_moving_rightwards = false;
285 } else {
286 pos.x += 1;
287 }
288 } else {
289 if pos.x > 0 {
290 pos.x -= 1;
291 }
292 if pos.x == 0 {
293 *is_moving_rightwards = true;
294 }
295 }
296 return true;
297 }
298 }
299 false
300 }
301
302 fn render_resized_image(&mut self, f: &mut Frame<'_>, resize: Resize, area: Rect) {
303 let (state, name, color) = match resize {
304 Resize::Fit(_) => (&mut self.image_fit_state, "Fit", Color::Magenta),
305 Resize::Crop(_) => (&mut self.image_crop_state, "Crop", Color::Green),
306 Resize::Scale(_) => (&mut self.image_scale_state, "Scale", Color::Blue),
307 };
308 let block = block(name);
309 let inner_area = block.inner(area);
310 f.render_widget(paragraph(self.background.as_str().bg(color)), inner_area);
311 if self.show_images != ShowImages::Fixed {
312 f.render_stateful_widget(StatefulImage::new().resize(resize), inner_area, state);
313 }
314 f.render_widget(block, area);
315 }
316}
317
318fn ui(f: &mut Frame<'_>, app: &mut App) {
319 let outer_block = Block::default()
320 .borders(Borders::TOP)
321 .title(app.title.as_str());
322
323 let chunks = Layout::default()
324 .direction(Direction::Horizontal)
325 .constraints([
326 Constraint::Percentage(app.split_percent),
327 Constraint::Percentage(100 - app.split_percent),
328 ])
329 .split(outer_block.inner(f.area()));
330 f.render_widget(outer_block, f.area());
331
332 let left_chunks = vertical_layout().split(chunks[0]);
333 let right_chunks = vertical_layout().split(chunks[1]);
334
335 let chunks_left_top = Layout::default()
336 .direction(Direction::Horizontal)
337 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
338 .split(left_chunks[0]);
339
340 let block_left_top = block("Fixed");
341 let area = block_left_top.inner(chunks_left_top[0]);
342 f.render_widget(
343 paragraph(app.background.as_str()).style(Color::Yellow),
344 area,
345 );
346 f.render_widget(block_left_top, chunks_left_top[0]);
347 if app.show_images != ShowImages::Resized {
348 let area = Rect {
350 x: area.x + 1,
351 y: area.y + 1,
352 width: area.width.saturating_sub(2),
353 height: area.height.saturating_sub(2),
354 };
355
356 if let Some(placeholder_area) = app.image_static.needs_placeholder(area) {
357 let placeholder = Block::bordered()
358 .border_type(BorderType::QuadrantOutside)
359 .bg(Color::DarkGray);
360 f.render_widget(Clear {}, placeholder.inner(placeholder_area));
361 f.render_widget(placeholder, placeholder_area);
362 } else {
363 let image = Image::new(&app.image_static).allow_clipping(true);
364 f.render_widget(image, area);
365 }
366 }
367
368 let block_middle_top = block("Sliced");
369 let area = block_middle_top.inner(chunks_left_top[1]);
370 app.image_sliced_viewport = Some(area.into());
371 f.render_widget(
372 paragraph(app.background.as_str()).style(Color::LightBlue),
373 area,
374 );
375 f.render_widget(block_middle_top, chunks_left_top[1]);
376 if app.show_images != ShowImages::Resized {
377 let (pos, _, _) = app.image_sliced_position;
378 let image = SlicedImage::new(&app.image_sliced, pos);
379 f.render_widget(image, area);
380 }
381
382 let chunks_left_bottom = Layout::default()
383 .direction(Direction::Horizontal)
384 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
385 .split(left_chunks[1]);
386
387 app.render_resized_image(f, Resize::Crop(None), chunks_left_bottom[0]);
388 app.render_resized_image(f, Resize::Scale(None), chunks_left_bottom[1]);
389 app.render_resized_image(f, Resize::Fit(None), right_chunks[0]);
390
391 let block_right_bottom = block("Help");
392 let area = block_right_bottom.inner(right_chunks[1]);
393 f.render_widget(
394 paragraph(vec![
395 Line::from(format!(
396 "Font size: {}×{}",
397 app.picker.font_size().width,
398 app.picker.font_size().height
399 )),
400 Line::from(format!("Protocol: {:?}", app.picker.protocol_type())),
401 Line::from("Key bindings:"),
402 Line::from(vec![
403 Span::from("H").green(),
404 Span::from("/"),
405 Span::from("L").green(),
406 Span::from(": resize panes"),
407 ]),
408 Line::from(vec![Span::from("o").green(), Span::from(": cycle image")]),
409 Line::from(vec![
410 Span::from("t").green(),
411 Span::from(format!(": toggle ({:?})", app.show_images)),
412 ]),
413 Line::from(vec![
414 Span::from("h").green(),
415 Span::from("/"),
416 Span::from("j").green(),
417 Span::from("/"),
418 Span::from("k").green(),
419 Span::from("/"),
420 Span::from("l").green(),
421 Span::from(": move"),
422 ]),
423 Line::from(vec![
424 Span::from("a").green(),
425 Span::from(": toggle animation"),
426 ]),
427 ]),
428 area,
429 );
430 f.render_widget(block_right_bottom, right_chunks[1]);
431}
432
433fn paragraph<'a, T: Into<Text<'a>>>(str: T) -> Paragraph<'a> {
434 Paragraph::new(str).wrap(Wrap { trim: true })
435}
436
437fn vertical_layout() -> Layout {
438 Layout::default()
439 .direction(Direction::Vertical)
440 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
441}
442
443fn block(name: &str) -> Block<'_> {
444 Block::default().borders(Borders::ALL).title(name)
445}