tangled
alpha
login
or
join now
desertthunder.dev
/
lantern
3
fork
atom
magical markdown slides
3
fork
atom
overview
issues
pulls
pipelines
chore: clippy
desertthunder.dev
3 months ago
f522cbe1
2b57f451
+65
-81
9 changed files
expand all
collapse all
unified
split
cli
src
main.rs
core
src
metadata.rs
parser.rs
printer.rs
theme.rs
validator.rs
ui
src
layout.rs
renderer.rs
viewer.rs
+13
-11
cli/src/main.rs
···
1
1
/// TODO: Add --no-bg flag to present command to allow users to disable background color
2
2
use clap::{Parser, Subcommand};
3
3
+
use lantern_core::validator::{validate_slides, validate_theme_file};
3
4
use lantern_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeRegistry};
4
5
use lantern_ui::App;
6
6
+
use owo_colors::OwoColorize;
5
7
use ratatui::{Terminal, backend::CrosstermBackend};
6
6
-
use std::{io, path::PathBuf};
8
8
+
use std::{
9
9
+
io,
10
10
+
path::{Path, PathBuf},
11
11
+
};
7
12
use tracing::Level;
8
13
9
14
/// A modern terminal-based presentation tool
···
74
79
.write(true)
75
80
.truncate(true)
76
81
.open(&log_path)
77
77
-
.unwrap_or_else(|e| panic!("Failed to create log file at {}: {}", log_path, e));
82
82
+
.unwrap_or_else(|e| panic!("Failed to create log file at {log_path}: {e}"));
78
83
79
84
tracing_subscriber::fmt()
80
85
.with_max_level(cli.log_level)
···
92
97
match cli.command {
93
98
Commands::Present { file, theme } => {
94
99
if let Err(e) = run_present(&file, theme) {
95
95
-
eprintln!("Error: {}", e);
100
100
+
eprintln!("Error: {e}");
96
101
std::process::exit(1);
97
102
}
98
103
}
99
104
Commands::Print { file, width, theme } => {
100
105
if let Err(e) = run_print(&file, width, theme) {
101
101
-
eprintln!("Error: {}", e);
106
106
+
eprintln!("Error: {e}");
102
107
std::process::exit(1);
103
108
}
104
109
}
···
108
113
}
109
114
Commands::Check { file, strict, theme } => {
110
115
if let Err(e) = run_check(&file, strict, theme) {
111
111
-
eprintln!("Error: {}", e);
116
116
+
eprintln!("Error: {e}");
112
117
std::process::exit(1);
113
118
}
114
119
}
···
122
127
.map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?;
123
128
124
129
let (meta, slides) = parse_slides_with_meta(&markdown)
125
125
-
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {}", e)))?;
130
130
+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {e}")))?;
126
131
127
132
if slides.is_empty() {
128
133
return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file"));
···
164
169
result
165
170
}
166
171
167
167
-
fn run_check(file: &PathBuf, strict: bool, is_theme: bool) -> io::Result<()> {
168
168
-
use lantern_core::validator::{validate_slides, validate_theme_file};
169
169
-
use owo_colors::OwoColorize;
170
170
-
172
172
+
fn run_check(file: &Path, strict: bool, is_theme: bool) -> io::Result<()> {
171
173
if is_theme {
172
174
tracing::info!("Validating theme file: {}", file.display());
173
175
let result = validate_theme_file(file);
···
228
230
.map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?;
229
231
230
232
let (meta, slides) = parse_slides_with_meta(&markdown)
231
231
-
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {}", e)))?;
233
233
+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {e}")))?;
232
234
233
235
if slides.is_empty() {
234
236
return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file"));
+5
-7
core/src/metadata.rs
···
41
41
match format {
42
42
FrontmatterFormat::Yaml => match serde_yml::from_str(header) {
43
43
Ok(meta) => Ok(meta),
44
44
-
Err(e) => Err(SlideError::front_matter(format!("Failed to parse YAML: {}", e))),
44
44
+
Err(e) => Err(SlideError::front_matter(format!("Failed to parse YAML: {e}"))),
45
45
},
46
46
FrontmatterFormat::Toml => match toml::from_str(header) {
47
47
Ok(meta) => Ok(meta),
48
48
-
Err(e) => Err(SlideError::front_matter(format!("Failed to parse TOML: {}", e))),
48
48
+
Err(e) => Err(SlideError::front_matter(format!("Failed to parse TOML: {e}"))),
49
49
},
50
50
}
51
51
}
52
52
53
53
/// Extract frontmatter block with the given delimiter and format
54
54
fn extract_frontmatter(rest: &str, delimiter: &str, format: FrontmatterFormat) -> Result<(Self, String)> {
55
55
-
match rest.find(&format!("\n{}", delimiter)) {
55
55
+
match rest.find(&format!("\n{delimiter}")) {
56
56
Some(end_pos) => Ok((
57
57
Self::parse(&rest[..end_pos], format)?,
58
58
rest[end_pos + delimiter.len() + 1..].to_string(),
59
59
)),
60
60
None => Err(SlideError::front_matter(format!(
61
61
-
"Unclosed {} frontmatter block (missing closing {})",
62
62
-
format, delimiter
61
61
+
"Unclosed {format} frontmatter block (missing closing {delimiter})"
63
62
))),
64
63
}
65
64
}
···
97
96
let day_of_year = epoch_days % 365;
98
97
let month = (day_of_year / 30) + 1;
99
98
let day = (day_of_year % 30) + 1;
100
100
-
format!("{:04}-{:02}-{:02}", year, month, day)
99
99
+
format!("{year:04}-{month:02}-{day:02}")
101
100
}
102
101
Err(_) => "Unknown".to_string(),
103
102
}
···
125
124
FrontmatterFormat::Yaml => "YAML",
126
125
FrontmatterFormat::Toml => "TOML",
127
126
}
128
128
-
.to_string()
129
127
)
130
128
}
131
129
}
+6
-6
core/src/parser.rs
···
161
161
}) = block_stack.last_mut()
162
162
{
163
163
if !current_row.is_empty() {
164
164
-
*headers = current_row.drain(..).collect();
164
164
+
*headers = std::mem::take(current_row);
165
165
}
166
166
*in_header = false;
167
167
}
···
174
174
}) = block_stack.last_mut()
175
175
{
176
176
if !current_row.is_empty() {
177
177
-
rows.push(current_row.drain(..).collect());
177
177
+
rows.push(std::mem::take(current_row));
178
178
}
179
179
}
180
180
}
···
185
185
..
186
186
}) = block_stack.last_mut()
187
187
{
188
188
-
current_row.push(current_cell.drain(..).collect());
188
188
+
current_row.push(std::mem::take(current_cell));
189
189
}
190
190
}
191
191
TagEnd::Item => {
···
195
195
{
196
196
if !current_item.is_empty() {
197
197
items.push(ListItem {
198
198
-
spans: current_item.drain(..).collect(),
198
198
+
spans: std::mem::take(current_item),
199
199
nested: None,
200
200
});
201
201
}
···
543
543
544
544
match &slides[0].blocks[0] {
545
545
Block::Table(table) => {
546
546
-
assert_eq!(table.rows[0][0][0].style.bold, true);
547
547
-
assert_eq!(table.rows[0][1][0].style.code, true);
546
546
+
assert!(table.rows[0][0][0].style.bold);
547
547
+
assert!(table.rows[0][1][0].style.code);
548
548
}
549
549
_ => panic!("Expected table"),
550
550
}
+9
-9
core/src/printer.rs
···
22
22
writeln!(writer)?;
23
23
let sep_text = "═".repeat(width);
24
24
let separator = theme.rule(&sep_text);
25
25
-
writeln!(writer, "{}", separator)?;
25
25
+
writeln!(writer, "{separator}")?;
26
26
writeln!(writer)?;
27
27
}
28
28
···
118
118
current_line.push(' ');
119
119
current_line.push_str(word);
120
120
} else {
121
121
-
write!(writer, "{}", indent_str)?;
121
121
+
write!(writer, "{indent_str}")?;
122
122
for span in spans {
123
123
if current_line.contains(&span.text) {
124
124
print_span(writer, span, theme, false)?;
···
134
134
}
135
135
136
136
if !current_line.is_empty() {
137
137
-
write!(writer, "{}", indent_str)?;
137
137
+
write!(writer, "{indent_str}")?;
138
138
for span in spans {
139
139
print_span(writer, span, theme, false)?;
140
140
}
···
149
149
writer: &mut W, code: &CodeBlock, theme: &ThemeColors, width: usize,
150
150
) -> std::io::Result<()> {
151
151
if let Some(lang) = &code.language {
152
152
-
writeln!(writer, "{}", theme.code_fence(&format!("```{}", lang)))?;
152
152
+
writeln!(writer, "{}", theme.code_fence(&format!("```{lang}")))?;
153
153
} else {
154
154
writeln!(writer, "{}", theme.code_fence(&"```"))?;
155
155
}
···
179
179
180
180
/// Print a list with bullets or numbers
181
181
fn print_list<W: std::io::Write>(
182
182
-
writer: &mut W, list: &List, theme: &ThemeColors, width: usize, indent: usize,
182
182
+
writer: &mut W, list: &List, theme: &ThemeColors, _width: usize, indent: usize,
183
183
) -> std::io::Result<()> {
184
184
for (idx, item) in list.items.iter().enumerate() {
185
185
let marker = if list.ordered { format!("{}. ", idx + 1) } else { "• ".to_string() };
···
194
194
writeln!(writer)?;
195
195
196
196
if let Some(nested) = &item.nested {
197
197
-
print_list(writer, nested, theme, width, indent + 2)?;
197
197
+
print_list(writer, nested, theme, _width, indent + 2)?;
198
198
}
199
199
}
200
200
···
357
357
let mut result = styled.to_string();
358
358
359
359
if text_style.bold {
360
360
-
result = format!("\x1b[1m{}\x1b[22m", result);
360
360
+
result = format!("\x1b[1m{result}\x1b[22m");
361
361
}
362
362
if text_style.italic {
363
363
-
result = format!("\x1b[3m{}\x1b[23m", result);
363
363
+
result = format!("\x1b[3m{result}\x1b[23m");
364
364
}
365
365
if text_style.strikethrough {
366
366
-
result = format!("\x1b[9m{}\x1b[29m", result);
366
366
+
result = format!("\x1b[9m{result}\x1b[29m");
367
367
}
368
368
369
369
result
+1
-2
core/src/theme.rs
···
660
660
let styled = theme.heading(&"Test");
661
661
assert!(
662
662
styled.to_string().contains("Test"),
663
663
-
"Theme '{}' failed to parse or apply styles",
664
664
-
theme_name
663
663
+
"Theme '{theme_name}' failed to parse or apply styles"
665
664
);
666
665
}
667
666
}
+4
-3
core/src/validator.rs
···
2
2
use crate::metadata::Meta;
3
3
use crate::parser::parse_slides_with_meta;
4
4
use crate::theme::{Base16Scheme, ThemeColors, ThemeRegistry};
5
5
+
5
6
use std::path::Path;
6
7
7
8
/// Validation result containing errors and warnings
···
55
56
let (meta, slides) = match parse_slides_with_meta(&markdown) {
56
57
Ok((m, s)) => (m, s),
57
58
Err(e) => {
58
58
-
result.add_error(format!("Parse error: {}", e));
59
59
+
result.add_error(format!("Parse error: {e}"));
59
60
return result;
60
61
}
61
62
};
···
118
119
let scheme: Base16Scheme = match serde_yml::from_str(&yaml_content) {
119
120
Ok(s) => s,
120
121
Err(e) => {
121
121
-
result.add_error(format!("Failed to parse YAML: {}", e));
122
122
+
result.add_error(format!("Failed to parse YAML: {e}"));
122
123
return result;
123
124
}
124
125
};
···
187
188
}
188
189
189
190
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
190
190
-
result.add_error(format!("Color {} contains invalid hex characters", name));
191
191
+
result.add_error(format!("Color {name} contains invalid hex characters"));
191
192
}
192
193
}
193
194
+3
-7
ui/src/layout.rs
···
3
3
/// Layout manager for slide presentation
4
4
///
5
5
/// Calculates screen layout with main slide area, optional notes panel, status bar, and optional help line.
6
6
+
#[derive(Default)]
6
7
pub struct SlideLayout {
7
8
show_notes: bool,
8
9
show_help: bool,
···
80
81
}
81
82
}
82
83
83
83
-
impl Default for SlideLayout {
84
84
-
fn default() -> Self {
85
85
-
Self { show_notes: false, show_help: false }
86
86
-
}
87
87
-
}
88
84
89
85
#[cfg(test)]
90
86
mod tests {
···
148
144
let main_percentage = (main.width as f32 / area.width as f32) * 100.0;
149
145
let notes_percentage = (notes_area.width as f32 / area.width as f32) * 100.0;
150
146
151
151
-
assert!(main_percentage >= 55.0 && main_percentage <= 65.0);
152
152
-
assert!(notes_percentage >= 35.0 && notes_percentage <= 45.0);
147
147
+
assert!((55.0..=65.0).contains(&main_percentage));
148
148
+
assert!((35.0..=45.0).contains(¬es_percentage));
153
149
}
154
150
155
151
#[test]
+17
-29
ui/src/renderer.rs
···
1
1
-
use ratatui::{
2
2
-
style::{Modifier, Style},
3
3
-
text::{Line, Span, Text},
4
4
-
};
5
1
use lantern_core::{
6
2
highlighter,
7
3
slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle},
8
4
theme::ThemeColors,
9
5
};
6
6
+
use ratatui::{
7
7
+
style::{Modifier, Style},
8
8
+
text::{Line, Span, Text},
9
9
+
};
10
10
11
11
/// Render a slide's blocks into ratatui Text
12
12
///
···
67
67
let fence_style = to_ratatui_style(&theme.code_fence, false);
68
68
69
69
if let Some(lang) = &code.language {
70
70
-
lines.push(Line::from(Span::styled(format!("```{}", lang), fence_style)));
70
70
+
lines.push(Line::from(Span::styled(format!("```{lang}"), fence_style)));
71
71
} else {
72
72
lines.push(Line::from(Span::styled("```".to_string(), fence_style)));
73
73
}
···
123
123
let border_style = to_ratatui_style(&theme.blockquote_border, false);
124
124
125
125
for block in blocks {
126
126
-
match block {
127
127
-
Block::Paragraph { spans } => {
128
128
-
let mut line_spans = vec![Span::styled("│ ".to_string(), border_style)];
126
126
+
if let Block::Paragraph { spans } = block {
127
127
+
let mut line_spans = vec![Span::styled("│ ".to_string(), border_style)];
129
128
130
130
-
for span in spans {
131
131
-
line_spans.push(create_span(span, theme, false));
132
132
-
}
133
133
-
134
134
-
lines.push(Line::from(line_spans));
129
129
+
for span in spans {
130
130
+
line_spans.push(create_span(span, theme, false));
135
131
}
136
136
-
_ => {}
132
132
+
133
133
+
lines.push(Line::from(line_spans));
137
134
}
138
135
}
139
136
}
···
214
211
215
212
#[cfg(test)]
216
213
mod tests {
217
217
-
use lantern_core::slide::ListItem;
218
218
-
219
214
use super::*;
220
215
216
216
+
use lantern_core::slide::ListItem;
217
217
+
use lantern_core::theme::Color;
218
218
+
221
219
#[test]
222
220
fn render_heading_basic() {
223
221
let blocks = vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Test Heading")] }];
···
275
273
276
274
#[test]
277
275
fn to_ratatui_style_converts_color() {
278
278
-
use lantern_core::theme::Color;
279
279
-
280
276
let color = Color::new(255, 128, 64);
281
277
let style = to_ratatui_style(&color, false);
282
278
···
285
281
286
282
#[test]
287
283
fn to_ratatui_style_applies_bold() {
288
288
-
use lantern_core::theme::Color;
289
289
-
290
284
let color = Color::new(100, 150, 200);
291
285
let style = to_ratatui_style(&color, true);
292
286
···
296
290
297
291
#[test]
298
292
fn to_ratatui_style_no_bold_when_false() {
299
299
-
use lantern_core::theme::Color;
300
300
-
301
293
let color = Color::new(100, 150, 200);
302
294
let style = to_ratatui_style(&color, false);
303
303
-
304
295
assert!(!style.add_modifier.contains(Modifier::BOLD));
305
296
}
306
297
···
308
299
fn render_heading_uses_theme_colors() {
309
300
let theme = ThemeColors::default();
310
301
let blocks = vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Colored Heading")] }];
311
311
-
312
302
let text = render_slide_content(&blocks, &theme);
313
303
assert!(!text.lines.is_empty());
314
314
-
assert!(text.lines.len() >= 1);
304
304
+
assert!(!text.lines.is_empty());
315
305
}
316
306
317
307
#[test]
318
308
fn apply_theme_style_respects_heading_bold() {
319
309
let theme = ThemeColors::default();
320
310
let text_style = TextStyle::default();
321
321
-
322
311
let style = apply_theme_style(&theme, &text_style, true);
323
312
assert!(style.add_modifier.contains(Modifier::BOLD));
324
313
}
···
326
315
#[test]
327
316
fn apply_theme_style_uses_code_color_for_code() {
328
317
let theme = ThemeColors::default();
329
329
-
let mut text_style = TextStyle::default();
330
330
-
text_style.code = true;
318
318
+
let text_style = TextStyle { code: true, ..Default::default() };
319
319
+
let style = apply_theme_style(&theme, &text_style, false);
331
320
332
332
-
let style = apply_theme_style(&theme, &text_style, false);
333
321
assert_eq!(
334
322
style.fg,
335
323
Some(ratatui::style::Color::Rgb(theme.code.r, theme.code.g, theme.code.b))
+7
-7
ui/src/viewer.rs
···
152
152
self.slides.iter().any(|slide| slide.notes.is_some())
153
153
}
154
154
155
155
-
fn theme(&self) -> ThemeColors {
156
156
-
self.stylesheet.theme.clone()
157
157
-
}
158
158
-
159
155
/// Render the current slide to the frame
160
156
pub fn render(&self, frame: &mut Frame, area: Rect) {
161
157
if let Some(slide) = self.current_slide() {
···
207
203
208
204
/// Render status bar with navigation info
209
205
pub fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
210
210
-
let filename_part = self.filename.as_ref().map(|f| format!("{} | ", f)).unwrap_or_default();
206
206
+
let filename_part = self.filename.as_ref().map(|f| format!("{f} | ")).unwrap_or_default();
211
207
212
208
let elapsed = self
213
209
.start_time
···
217
213
let hours = secs / 3600;
218
214
let minutes = (secs % 3600) / 60;
219
215
let seconds = secs % 60;
220
220
-
format!(" | {:02}:{:02}:{:02}", hours, minutes, seconds)
216
216
+
format!(" | {hours:02}:{minutes:02}:{seconds:02}")
221
217
})
222
218
.unwrap_or_default();
223
219
···
257
253
let text_len = help_text.chars().count();
258
254
let padding = if text_len < width { " ".repeat(width - text_len) } else { String::new() };
259
255
260
260
-
let full_text = format!("{}{}", help_text, padding);
256
256
+
let full_text = format!("{help_text}{padding}");
261
257
262
258
let dimmed_style = Style::default().fg(Color::Rgb(100, 100, 100)).bg(Color::Rgb(
263
259
self.theme().ui_background.r,
···
268
264
let help_line = Paragraph::new(Line::from(vec![Span::styled(full_text, dimmed_style)]));
269
265
270
266
frame.render_widget(help_line, area);
267
267
+
}
268
268
+
269
269
+
fn theme(&self) -> ThemeColors {
270
270
+
self.stylesheet.theme
271
271
}
272
272
}
273
273