···2233> A modern, fast, terminal presentation tool inspired by [`maaslalani/slides`](https://github.com/maaslalani/slides), built with Rust.
4455+<details>
66+<summary>
77+Now with image support (if your terminal supports it!)
88+</summary>
99+1010+
1111+1212+
1313+1414+</details>
1515+516## Quickstart
617718### Installation
···5050 Table(Table),
5151 /// Admonition/alert box with type, optional title, and content
5252 Admonition(Admonition),
5353+ /// Image with path and alt text
5454+ Image { path: String, alt: String },
5355}
54565557/// Styled text span within a block
···11+use image::DynamicImage;
22+use ratatui_image::{picker::Picker, protocol::StatefulProtocol};
33+use std::collections::HashMap;
44+use std::io;
55+use std::path::{Path, PathBuf};
66+77+/// Manages image loading and protocol state for terminal rendering
88+///
99+/// Handles image loading from paths, protocol detection, and caching of loaded images.
1010+pub struct ImageManager {
1111+ picker: Picker,
1212+ protocols: HashMap<String, StatefulProtocol>,
1313+ base_path: Option<PathBuf>,
1414+}
1515+1616+impl ImageManager {
1717+ /// Create a new ImageManager with protocol detection
1818+ pub fn new() -> io::Result<Self> {
1919+ let picker = Picker::from_query_stdio().map_err(io::Error::other)?;
2020+2121+ Ok(Self { picker, protocols: HashMap::new(), base_path: None })
2222+ }
2323+2424+ /// Set the base path for resolving relative image paths
2525+ pub fn set_base_path(&mut self, path: impl AsRef<Path>) {
2626+ self.base_path = Some(path.as_ref().to_path_buf());
2727+ }
2828+2929+ /// Load an image from a path and create a protocol for it
3030+ ///
3131+ /// Returns a reference to the protocol if successful.
3232+ pub fn load_image(&mut self, path: &str) -> io::Result<&mut StatefulProtocol> {
3333+ if !self.protocols.contains_key(path) {
3434+ let image_path = self.resolve_path(path);
3535+ let dyn_img = load_image_from_path(&image_path)?;
3636+ let protocol = self.picker.new_resize_protocol(dyn_img);
3737+ self.protocols.insert(path.to_string(), protocol);
3838+ }
3939+4040+ Ok(self.protocols.get_mut(path).unwrap())
4141+ }
4242+4343+ /// Check if an image is already loaded
4444+ pub fn has_image(&self, path: &str) -> bool {
4545+ self.protocols.contains_key(path)
4646+ }
4747+4848+ /// Get a mutable reference to a loaded image protocol
4949+ pub fn get_protocol_mut(&mut self, path: &str) -> Option<&mut StatefulProtocol> {
5050+ self.protocols.get_mut(path)
5151+ }
5252+5353+ /// Resolve a path relative to the base path if set
5454+ fn resolve_path(&self, path: &str) -> PathBuf {
5555+ let path = Path::new(path);
5656+5757+ if path.is_absolute() {
5858+ return path.to_path_buf();
5959+ }
6060+6161+ if let Some(base) = &self.base_path {
6262+ if let Some(parent) = base.parent() {
6363+ return parent.join(path);
6464+ }
6565+ }
6666+6767+ path.to_path_buf()
6868+ }
6969+}
7070+7171+impl Default for ImageManager {
7272+ fn default() -> Self {
7373+ Self::new().unwrap_or_else(|_| Self {
7474+ picker: Picker::from_fontsize((8, 16)),
7575+ protocols: HashMap::new(),
7676+ base_path: None,
7777+ })
7878+ }
7979+}
8080+8181+/// Load an image from a file path
8282+fn load_image_from_path(path: &Path) -> io::Result<DynamicImage> {
8383+ image::ImageReader::open(path)
8484+ .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?
8585+ .decode()
8686+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
8787+}
8888+8989+#[cfg(test)]
9090+mod tests {
9191+ use super::*;
9292+9393+ #[test]
9494+ fn resolve_path_absolute() {
9595+ let mut manager = ImageManager::default();
9696+ manager.set_base_path("/home/user/slides.md");
9797+ let resolved = manager.resolve_path("/tmp/image.png");
9898+ assert_eq!(resolved, PathBuf::from("/tmp/image.png"));
9999+ }
100100+101101+ #[test]
102102+ fn resolve_path_relative() {
103103+ let mut manager = ImageManager::default();
104104+ manager.set_base_path("/home/user/slides.md");
105105+ let resolved = manager.resolve_path("images/test.png");
106106+ assert_eq!(resolved, PathBuf::from("/home/user/images/test.png"));
107107+ }
108108+109109+ #[test]
110110+ fn resolve_path_no_base() {
111111+ let manager = ImageManager::default();
112112+ let resolved = manager.resolve_path("test.png");
113113+ assert_eq!(resolved, PathBuf::from("test.png"));
114114+ }
115115+116116+ #[test]
117117+ fn has_image_returns_false_for_unloaded() {
118118+ let manager = ImageManager::default();
119119+ assert!(!manager.has_image("test.png"));
120120+ }
121121+}
+3-1
ui/src/lib.rs
···11pub mod app;
22+pub mod image;
23pub mod layout;
34pub mod renderer;
45pub mod viewer;
5667pub use app::App;
88+pub use image::ImageManager;
79pub use layout::SlideLayout;
88-pub use renderer::render_slide_content;
1010+pub use renderer::{ImageInfo, render_slide_content, render_slide_with_images};
911pub use viewer::SlideViewer;
10121113pub use lantern_core::{
+90-10
ui/src/renderer.rs
···99};
1010use unicode_width::UnicodeWidthChar;
11111212+/// Image information extracted from blocks
1313+pub struct ImageInfo {
1414+ pub path: String,
1515+ pub alt: String,
1616+}
1717+1818+/// Render a slide's blocks and extract images
1919+///
2020+/// Returns both the text content and a list of images found in the blocks.
2121+pub fn render_slide_with_images(blocks: &[Block], theme: &ThemeColors) -> (Text<'static>, Vec<ImageInfo>) {
2222+ let mut lines = Vec::new();
2323+ let mut images = Vec::new();
2424+2525+ for block in blocks {
2626+ match block {
2727+ Block::Heading { level, spans } => render_heading(*level, spans, theme, &mut lines),
2828+ Block::Paragraph { spans } => render_paragraph(spans, theme, &mut lines),
2929+ Block::Code(code_block) => render_code_block(code_block, theme, &mut lines),
3030+ Block::List(list) => render_list(list, theme, &mut lines, 0),
3131+ Block::Rule => render_rule(theme, &mut lines),
3232+ Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines),
3333+ Block::Table(table) => render_table(table, theme, &mut lines),
3434+ Block::Admonition(admonition) => render_admonition(admonition, theme, &mut lines),
3535+ Block::Image { path, alt } => images.push(ImageInfo { path: path.clone(), alt: alt.clone() }),
3636+ }
3737+3838+ lines.push(Line::raw(""));
3939+ }
4040+4141+ (Text::from(lines), images)
4242+}
4343+1244/// Render a slide's blocks into ratatui Text
1345///
1446/// Converts slide blocks into styled ratatui text with theming applied.
···2557 Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines),
2658 Block::Table(table) => render_table(table, theme, &mut lines),
2759 Block::Admonition(admonition) => render_admonition(admonition, theme, &mut lines),
6060+ // Images are handled separately when using render_slide_with_images
6161+ Block::Image { .. } => {}
2862 }
29633064 lines.push(Line::raw(""));
···3468}
35693670/// Get heading prefix using Unicode block symbols
7171+/// 1. (*h1*) Large block / heavy fill (`U+2589`)
7272+/// 2. (*h2*) Dark shade (`U+2593`)
7373+/// 3. (*h3*) Medium shade (`U+2592`)
7474+/// 4. (*h4*) Light shade (`U+2591`)
7575+/// 5. (*h5*) Left half block (`U+258C`)
7676+/// 6. (*h6*) Left half block (`U+258C`)
3777fn get_prefix(level: u8) -> &'static str {
3878 match level {
3939- 1 => "▉ ", // Large block / heavy fill (U+2589)
4040- 2 => "▓ ", // Dark shade (U+2593)
4141- 3 => "▒ ", // Medium shade (U+2592)
4242- 4 => "░ ", // Light shade (U+2591)
4343- 5 => "▌ ", // Left half block (U+258C)
4444- _ => "▌ ", // Left half block (U+258C) for h6
7979+ 1 => "▉ ",
8080+ 2 => "▓ ",
8181+ 3 => "▒ ",
8282+ 4 => "░ ",
8383+ 5 => "▌ ",
8484+ _ => "▌ ",
4585 }
4686}
4787···116156/// Render a horizontal rule
117157fn render_rule(theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
118158 let rule_style = to_ratatui_style(&theme.rule, false);
119119- let rule = "─".repeat(60);
120120- lines.push(Line::from(Span::styled(rule, rule_style)));
159159+ lines.push(Line::from(Span::styled("─".repeat(60), rule_style)));
121160}
122161123162/// Render a blockquote with indentation
···367406 fn to_ratatui_style_converts_color() {
368407 let color = Color::new(255, 128, 64);
369408 let style = to_ratatui_style(&color, false);
370370-371409 assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(255, 128, 64)));
372410 }
373411···375413 fn to_ratatui_style_applies_bold() {
376414 let color = Color::new(100, 150, 200);
377415 let style = to_ratatui_style(&color, true);
378378-379416 assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(100, 150, 200)));
380417 assert!(style.add_modifier.contains(Modifier::BOLD));
381418 }
···414451 style.fg,
415452 Some(ratatui::style::Color::Rgb(theme.code.r, theme.code.g, theme.code.b))
416453 );
454454+ }
455455+456456+ #[test]
457457+ fn render_slide_with_images_extracts_image() {
458458+ let blocks =
459459+ vec![lantern_core::slide::Block::Image { path: "test.png".to_string(), alt: "Test Image".to_string() }];
460460+ let theme = ThemeColors::default();
461461+ let (_text, images) = render_slide_with_images(&blocks, &theme);
462462+463463+ assert_eq!(images.len(), 1);
464464+ assert_eq!(images[0].path, "test.png");
465465+ assert_eq!(images[0].alt, "Test Image");
466466+ }
467467+468468+ #[test]
469469+ fn render_slide_with_images_extracts_multiple() {
470470+ let blocks = vec![
471471+ lantern_core::slide::Block::Image { path: "image1.png".to_string(), alt: "First".to_string() },
472472+ lantern_core::slide::Block::Image { path: "image2.png".to_string(), alt: "Second".to_string() },
473473+ ];
474474+ let theme = ThemeColors::default();
475475+ let (_text, images) = render_slide_with_images(&blocks, &theme);
476476+477477+ assert_eq!(images.len(), 2);
478478+ assert_eq!(images[0].path, "image1.png");
479479+ assert_eq!(images[0].alt, "First");
480480+ assert_eq!(images[1].path, "image2.png");
481481+ assert_eq!(images[1].alt, "Second");
482482+ }
483483+484484+ #[test]
485485+ fn render_slide_with_mixed_content() {
486486+ let blocks = vec![
487487+ lantern_core::slide::Block::Heading { level: 1, spans: vec![TextSpan::plain("Title")] },
488488+ lantern_core::slide::Block::Image { path: "diagram.png".to_string(), alt: "Diagram".to_string() },
489489+ lantern_core::slide::Block::Paragraph { spans: vec![TextSpan::plain("Description")] },
490490+ ];
491491+ let theme = ThemeColors::default();
492492+ let (text, images) = render_slide_with_images(&blocks, &theme);
493493+494494+ assert!(!text.lines.is_empty());
495495+ assert_eq!(images.len(), 1);
496496+ assert_eq!(images[0].path, "diagram.png");
417497 }
418498}
+109-6
ui/src/viewer.rs
···11use lantern_core::{slide::Slide, theme::ThemeColors};
22use ratatui::{
33 Frame,
44- layout::Rect,
44+ layout::{Alignment, Constraint, Direction, Flex, Layout, Rect},
55 style::{Color, Modifier, Style},
66 text::{Line, Span},
77 widgets::{Block, Borders, Padding, Paragraph, Wrap},
88};
99+use ratatui_image::{Resize, StatefulImage};
910use std::time::Instant;
10111111-use crate::renderer::render_slide_content;
1212+use crate::image::ImageManager;
1313+use crate::renderer::render_slide_with_images;
12141315#[derive(Clone, Copy)]
1416struct Stylesheet {
···6971 stylesheet: Stylesheet,
7072 theme_name: String,
7173 start_time: Option<Instant>,
7474+ image_manager: ImageManager,
7275}
73767477impl SlideViewer {
···8285 filename: None,
8386 theme_name: "oxocarbon-dark".to_string(),
8487 start_time: None,
8888+ image_manager: ImageManager::default(),
8589 }
8690 }
8791···9094 slides: Vec<Slide>, theme: ThemeColors, filename: Option<String>, theme_name: String,
9195 start_time: Option<Instant>,
9296 ) -> Self {
9797+ let mut image_manager = ImageManager::default();
9898+ if let Some(ref path) = filename {
9999+ image_manager.set_base_path(path);
100100+ }
101101+93102 Self {
94103 slides,
95104 current_index: 0,
···98107 filename,
99108 theme_name,
100109 start_time,
110110+ image_manager,
101111 }
102112 }
103113···153163 }
154164155165 /// Render the current slide to the frame
156156- pub fn render(&self, frame: &mut Frame, area: Rect) {
166166+ pub fn render(&mut self, frame: &mut Frame, area: Rect) {
157167 if let Some(slide) = self.current_slide() {
158158- let content = render_slide_content(&slide.blocks, &self.theme());
168168+ let (content, images) = render_slide_with_images(&slide.blocks, &self.theme());
159169 let border_color = self.stylesheet.border_color();
160170 let title_color = self.stylesheet.title_color();
161171···166176 .title_style(Style::default().fg(title_color).add_modifier(Modifier::BOLD))
167177 .padding(Stylesheet::slide_padding());
168178169169- let paragraph = Paragraph::new(content).block(block).wrap(Wrap { trim: false });
179179+ let inner_area = block.inner(area);
180180+ frame.render_widget(block, area);
181181+182182+ let text_height = content.height() as u16;
183183+ let mut text_content = Some(content);
184184+185185+ if !images.is_empty() {
186186+ let total_images = images.len() as u16;
187187+ let border_height_per_image = 1;
188188+ let caption_height_per_image = 1;
189189+ let min_image_content_height = 1;
190190+ let min_height_per_image =
191191+ border_height_per_image + min_image_content_height + caption_height_per_image;
192192+ let min_images_height = total_images * min_height_per_image;
193193+194194+ let available_height = inner_area.height;
195195+ let max_text_height = available_height.saturating_sub(min_images_height);
196196+ let text_area_height = text_height.min(max_text_height);
197197+198198+ let chunks = Layout::default()
199199+ .direction(Direction::Vertical)
200200+ .constraints([Constraint::Length(text_area_height), Constraint::Min(min_images_height)])
201201+ .split(inner_area);
202202+203203+ if chunks[0].height > 0 {
204204+ if let Some(text) = text_content.take() {
205205+ let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
206206+ frame.render_widget(paragraph, chunks[0]);
207207+ }
208208+ }
209209+210210+ let constraints: Vec<Constraint> = (0..total_images)
211211+ .map(|_| Constraint::Ratio(1, total_images as u32))
212212+ .collect();
213213+214214+ let image_chunks = Layout::default()
215215+ .direction(Direction::Vertical)
216216+ .constraints(constraints)
217217+ .split(chunks[1]);
218218+219219+ for (idx, img_info) in images.iter().enumerate() {
220220+ if let Ok(protocol) = self.image_manager.load_image(&img_info.path) {
221221+ let image_area = image_chunks[idx];
222222+223223+ let horizontal_chunks = Layout::default()
224224+ .direction(Direction::Horizontal)
225225+ .constraints([
226226+ Constraint::Percentage(25),
227227+ Constraint::Percentage(50),
228228+ Constraint::Percentage(25),
229229+ ])
230230+ .split(image_area);
231231+232232+ let centered_area = horizontal_chunks[1];
233233+234234+ let image_block = Block::default()
235235+ .borders(Borders::ALL)
236236+ .border_style(Style::default().fg(border_color));
237237+238238+ let image_inner = image_block.inner(centered_area);
239239+ frame.render_widget(image_block, centered_area);
240240+241241+ let caption_height = if img_info.alt.is_empty() { 0 } else { 1 };
242242+ let content_chunks = Layout::default()
243243+ .direction(Direction::Vertical)
244244+ .constraints([Constraint::Length(caption_height), Constraint::Min(1)])
245245+ .flex(Flex::Center)
246246+ .split(image_inner);
247247+248248+ if caption_height > 0 {
249249+ let caption_style = Style::default()
250250+ .fg(Color::Rgb(150, 150, 150))
251251+ .add_modifier(Modifier::ITALIC);
252252+ let caption = Paragraph::new(Line::from(Span::styled(&img_info.alt, caption_style)))
253253+ .alignment(Alignment::Center);
254254+ frame.render_widget(caption, content_chunks[0]);
255255+ }
170256171171- frame.render_widget(paragraph, area);
257257+ let resize = Resize::Fit(None);
258258+ let image_size = protocol.size_for(resize, content_chunks[1]);
259259+260260+ let [centered_area] = Layout::horizontal([Constraint::Length(image_size.width)])
261261+ .flex(Flex::Center)
262262+ .areas(content_chunks[1]);
263263+ let [image_area] = Layout::vertical([Constraint::Length(image_size.height)])
264264+ .flex(Flex::Center)
265265+ .areas(centered_area);
266266+267267+ let image_widget = StatefulImage::default();
268268+ frame.render_stateful_widget(image_widget, image_area, protocol);
269269+ }
270270+ }
271271+ } else if let Some(text) = text_content.take() {
272272+ let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
273273+ frame.render_widget(paragraph, inner_area);
274274+ }
172275 }
173276 }
174277