Skip to main content

demo/
main.rs

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), // (x,y, is_moving_rightwards, is_animating)
70    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            // Query everything so that this is tested in demo and CI, but it's not used in the
93            // demo.
94            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                // Normally, we *never* would want to switch the detected protocol.
170                // This is for some debug session, where you want to test some other protocol than
171                // the detected.
172                // Changing "live" is also quite hazardous, as this will render some artifacts in
173                // between, or even trigger error messages, or crashes.
174                // If you need to "downgrade" e.g. to Halfblocks, then do it before any renders.
175                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            // This is normally only set by nixosTest.
258            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 it be surrounded by styled text.
349        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}