use eframe::{egui, epaint::Color32, App, CreationContext};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::fs;
use egui::ScrollArea;
use rfd::FileDialog;
use comrak::{markdown_to_html, ComrakOptions};
use serde::{Deserialize, Serialize};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::thread;
// Theme configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Theme {
name: String,
background_color: [u8; 3],
text_color: [u8; 3],
heading_colors: Vec<[u8; 3]>,
}
// Application state
struct MarkPrompter {
// File management
current_file: Option<PathBuf>,
content: String,
parsed_content: String,
// Scroll control
scroll_position: f32,
scroll_speed: f32, // pixels per second
is_playing: bool,
last_update: Instant,
// Display settings
font_size: f32,
// Feature toggles
pause_at_headings: bool,
auto_restart: bool,
heading_pause_duration: f32,
current_heading_pause: Option<f32>,
heading_line_indices: Vec<usize>,
last_checked_heading_idx: usize,
// Theme
current_theme: Theme,
available_themes: Vec<Theme>,
// File watcher
_file_watcher_tx: Option<Sender<()>>,
file_watcher_rx: Option<Receiver<()>>,
}
impl Default for Theme {
fn default() -> Self {
Theme {
name: "Default".to_string(),
background_color: [40, 44, 52],
text_color: [220, 223, 228],
heading_colors: vec![
[255, 180, 100], // H1
[230, 160, 90], // H2
[210, 140, 80], // H3
[190, 120, 70], // H4
[170, 100, 60], // H5
[150, 80, 50], // H6
],
}
}
}
impl Default for MarkPrompter {
fn default() -> Self {
MarkPrompter {
current_file: None,
content: String::new(),
parsed_content: String::new(),
scroll_position: 0.0,
scroll_speed: 50.0,
is_playing: false,
last_update: Instant::now(),
font_size: 18.0,
pause_at_headings: false,
auto_restart: false,
heading_pause_duration: 2.0,
current_heading_pause: None,
heading_line_indices: Vec::new(),
last_checked_heading_idx: 0,
current_theme: Theme::default(),
available_themes: vec![Theme::default()],
_file_watcher_tx: None,
file_watcher_rx: None,
}
}
}
impl MarkPrompter {
fn new(cc: &CreationContext) -> Self {
// Configure fonts
let mut style = (*cc.egui_ctx.style()).clone();
style.text_styles = [
(egui::TextStyle::Heading, egui::FontId::proportional(32.0)),
(egui::TextStyle::Body, egui::FontId::proportional(18.0)),
(egui::TextStyle::Monospace, egui::FontId::monospace(14.0)),
(egui::TextStyle::Button, egui::FontId::proportional(16.0)),
(egui::TextStyle::Small, egui::FontId::proportional(10.0)),
].into();
cc.egui_ctx.set_style(style);
// Load themes from config file if it exists
let mut app = Self::default();
if let Ok(themes) = load_themes() {
app.available_themes = themes;
if !app.available_themes.is_empty() {
app.current_theme = app.available_themes[0].clone();
}
}
app
}
fn open_file(&mut self) {
if let Some(path) = FileDialog::new()
.add_filter("Markdown", &["md", "markdown"])
.pick_file()
{
self.load_file(path);
}
}
fn load_file(&mut self, path: PathBuf) {
match fs::read_to_string(&path) {
Ok(content) => {
self.content = content;
self.parse_markdown();
self.current_file = Some(path.clone());
self.scroll_position = 0.0;
self.last_checked_heading_idx = 0;
// Set up file watcher
let (tx, rx) = channel();
self.file_watcher_rx = Some(rx);
let path_clone = path.clone();
let watcher_tx = tx.clone();
thread::spawn(move || {
let mut last_modified = fs::metadata(&path_clone).ok().map(|m| m.modified().ok()).flatten();
loop {
thread::sleep(Duration::from_secs(1));
if let Ok(metadata) = fs::metadata(&path_clone) {
if let Ok(modified) = metadata.modified() {
if let Some(last) = last_modified {
if modified > last {
let _ = watcher_tx.send(());
last_modified = Some(modified);
}
} else {
last_modified = Some(modified);
}
}
}
}
});
self._file_watcher_tx = Some(tx);
},
Err(e) => {
eprintln!("Error loading file: {}", e);
}
}
}
fn parse_markdown(&mut self) {
let mut options = ComrakOptions::default();
options.extension.strikethrough = true;
options.extension.table = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
self.parsed_content = markdown_to_html(&self.content, &options);
// Extract heading positions for pause-at-headings feature if enabled
if self.pause_at_headings {
self.extract_heading_positions();
}
}
fn extract_heading_positions(&mut self) {
// Simple approach: check each line for markdown heading markers
let lines: Vec<&str> = self.content.lines().collect();
let mut heading_line_indices = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.starts_with("# ") || line.starts_with("## ") ||
line.starts_with("### ") || line.starts_with("#### ") ||
line.starts_with("##### ") || line.starts_with("###### ") {
heading_line_indices.push(i);
}
}
// We'll use this information in the update_scroll method
self.heading_line_indices = heading_line_indices;
}
fn check_file_updates(&mut self) {
if let Some(rx) = &self.file_watcher_rx {
if rx.try_recv().is_ok() {
if let Some(path) = &self.current_file {
if let Ok(content) = fs::read_to_string(path) {
self.content = content;
self.parse_markdown();
}
}
}
}
}
fn update_scroll(&mut self, dt: f32) {
if !self.is_playing {
return;
}
// Handle heading pause if enabled
if let Some(remaining) = self.current_heading_pause {
if remaining > 0.0 {
self.current_heading_pause = Some(remaining - dt);
return;
} else {
self.current_heading_pause = None;
}
}
// Calculate new scroll position
self.scroll_position += self.scroll_speed * dt;
// Check if we should pause at a heading
if self.pause_at_headings && !self.heading_line_indices.is_empty() {
// Calculate approximate line based on scroll position and font size
let approximate_line = (self.scroll_position / (self.font_size * 1.5)) as usize;
// Check if we're approaching a heading
for (idx, &heading_line) in self.heading_line_indices.iter().enumerate().skip(self.last_checked_heading_idx) {
// If we've scrolled past this heading
if approximate_line >= heading_line && idx >= self.last_checked_heading_idx {
// Pause scrolling for the specified duration
self.current_heading_pause = Some(self.heading_pause_duration);
self.last_checked_heading_idx = idx + 1;
break;
}
}
}
}
}
impl App for MarkPrompter {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.check_file_updates();
// Set background color from theme
let bg_color = Color32::from_rgb(
self.current_theme.background_color[0],
self.current_theme.background_color[1],
self.current_theme.background_color[2]
);
let mut style = (*ctx.style()).clone();
style.visuals.panel_fill = bg_color;
style.visuals.window_fill = bg_color;
ctx.set_style(style);
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.set_height(ui.available_height());
// Controls panel
ui.vertical(|ui| {
ui.set_width(200.0);
ui.set_height(ui.available_height());
ui.heading("MarkPrompter");
ui.add_space(10.0);
// File controls
if ui.button("Open File").clicked() {
self.open_file();
}
ui.add_space(20.0);
ui.separator();
ui.add_space(10.0);
// Playback controls
ui.heading("Playback");
ui.add_space(5.0);
if ui.button(if self.is_playing { "⏸ Pause" } else { "▶ Play" }).clicked() {
self.is_playing = !self.is_playing;
self.last_update = Instant::now();
}
ui.add_space(5.0);
if ui.button("⏮ Restart").clicked() {
self.scroll_position = 0.0;
}
ui.add_space(10.0);
// Speed controls
ui.label("Scroll Speed");
ui.horizontal(|ui| {
if ui.small_button("-").clicked() {
self.scroll_speed = (self.scroll_speed - 10.0).max(10.0);
}
ui.label(format!("{}px/s", self.scroll_speed as i32));
if ui.small_button("+").clicked() {
self.scroll_speed = (self.scroll_speed + 10.0).min(500.0);
}
});
ui.add_space(20.0);
ui.separator();
ui.add_space(10.0);
// Feature toggles
ui.heading("Settings");
ui.add_space(5.0);
ui.checkbox(&mut self.pause_at_headings, "Pause at Headings");
if self.pause_at_headings {
ui.horizontal(|ui| {
ui.label("Pause Duration:");
ui.add(egui::Slider::new(&mut self.heading_pause_duration, 0.5..=10.0)
.suffix("s")
.text("seconds"));
});
}
ui.checkbox(&mut self.auto_restart, "Auto Restart");
ui.add_space(5.0);
// Font size
ui.horizontal(|ui| {
ui.label("Font Size:");
if ui.small_button("-").clicked() {
self.font_size = (self.font_size - 1.0).max(8.0);
}
ui.label(format!("{:.0}px", self.font_size));
if ui.small_button("+").clicked() {
self.font_size = (self.font_size + 1.0).min(72.0);
}
});
ui.add_space(20.0);
ui.separator();
ui.add_space(10.0);
// Theme selection
ui.heading("Theme");
ui.add_space(5.0);
egui::ComboBox::from_label("Select Theme")
.selected_text(self.current_theme.name.clone())
.show_ui(ui, |ui| {
for theme in &self.available_themes {
if ui.selectable_label(
self.current_theme.name == theme.name,
theme.name.clone()
).clicked() {
self.current_theme = theme.clone();
}
}
});
});
ui.separator();
// Content panel
ui.vertical(|ui| {
ui.set_width(ui.available_width());
ui.set_height(ui.available_height());
if let Some(file) = &self.current_file {
ui.heading(file.file_name().unwrap_or_default().to_string_lossy().to_string());
}
let text_color = Color32::from_rgb(
self.current_theme.text_color[0],
self.current_theme.text_color[1],
self.current_theme.text_color[2]
);
let scroll_area = ScrollArea::vertical()
.max_height(ui.available_height())
.vertical_scroll_offset(self.scroll_position);
let output = scroll_area.show(ui, |ui| {
// Calculate time delta for scrolling
let now = Instant::now();
let dt = now.duration_since(self.last_update).as_secs_f32();
self.last_update = now;
self.update_scroll(dt);
if !self.parsed_content.is_empty() {
// Custom markdown rendering with colored headings
let lines = self.content.lines().collect::<Vec<&str>>();
egui::Grid::new("markdown_content")
.num_columns(1)
.spacing([0.0, 5.0])
.striped(false)
.show(ui, |ui| {
for (_i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
// Detect heading level
let mut heading_level = 0;
if trimmed.starts_with("# ") { heading_level = 1; }
else if trimmed.starts_with("## ") { heading_level = 2; }
else if trimmed.starts_with("### ") { heading_level = 3; }
else if trimmed.starts_with("#### ") { heading_level = 4; }
else if trimmed.starts_with("##### ") { heading_level = 5; }
else if trimmed.starts_with("###### ") { heading_level = 6; }
// Apply appropriate color and styling based on whether it's a heading
if heading_level > 0 && heading_level <= self.current_theme.heading_colors.len() {
// It's a heading - use the appropriate heading color
let idx = heading_level - 1;
let heading_color = Color32::from_rgb(
self.current_theme.heading_colors[idx][0],
self.current_theme.heading_colors[idx][1],
self.current_theme.heading_colors[idx][2]
);
// Adjust font size based on heading level
let heading_size = self.font_size * (1.5 - (heading_level as f32 * 0.1));
ui.style_mut().text_styles.get_mut(&egui::TextStyle::Body).unwrap().size = heading_size;
ui.colored_label(heading_color, *line);
ui.end_row();
// Reset font size to default
ui.style_mut().text_styles.get_mut(&egui::TextStyle::Body).unwrap().size = self.font_size;
} else {
// Regular text - use the default text color
ui.colored_label(text_color, *line);
ui.end_row();
}
}
});
} else {
ui.colored_label(text_color, "Open a markdown file to begin.");
}
});
// Handle end-of-content scrolling behavior
if self.is_playing {
let content_height = output.inner_rect.height();
let available_height = ui.available_height();
if self.scroll_position >= content_height - available_height {
if self.auto_restart {
self.scroll_position = 0.0;
self.last_checked_heading_idx = 0;
} else {
self.scroll_position = (content_height - available_height).max(0.0);
self.is_playing = false;
}
}
}
});
});
});
// Request continuous repaint to enable smooth scrolling
ctx.request_repaint();
}
}
// Load themes from a TOML file
fn load_themes() -> Result<Vec<Theme>, Box<dyn std::error::Error>> {
let config_path = "themes.toml";
if !std::path::Path::new(config_path).exists() {
// Create a default theme file if it doesn't exist
let default_themes = vec![
Theme {
name: "Light".to_string(),
background_color: [240, 240, 245],
text_color: [60, 60, 70],
heading_colors: vec![
[100, 100, 180], // H1
[90, 90, 170], // H2
[80, 80, 160], // H3
[70, 70, 150], // H4
[60, 60, 140], // H5
[50, 50, 130], // H6
],
},
Theme {
name: "Dark".to_string(),
background_color: [40, 44, 52],
text_color: [220, 223, 228],
heading_colors: vec![
[255, 180, 100], // H1
[230, 160, 90], // H2
[210, 140, 80], // H3
[190, 120, 70], // H4
[170, 100, 60], // H5
[150, 80, 50], // H6
],
},
Theme {
name: "Solarized".to_string(),
background_color: [0, 43, 54],
text_color: [131, 148, 150],
heading_colors: vec![
[181, 137, 0], // H1
[203, 75, 22], // H2
[220, 50, 47], // H3
[211, 54, 130], // H4
[108, 113, 196], // H5
[38, 139, 210], // H6
],
},
];
let toml_string = toml::to_string(&default_themes)?;
fs::write(config_path, toml_string)?;
return Ok(default_themes);
}
let toml_str = fs::read_to_string(config_path)?;
// Parse TOML
#[derive(Deserialize)]
struct ThemesWrapper {
themes: Vec<Theme>,
}
// Try parsing as an array of themes first
let themes: Result<Vec<Theme>, _> = toml::from_str(&toml_str);
match themes {
Ok(themes) => Ok(themes),
Err(_) => {
// Try parsing with the wrapper structure
let wrapper: ThemesWrapper = toml::from_str(&toml_str)?;
Ok(wrapper.themes)
}
}
}
fn main() -> Result<(), eframe::Error> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1200.0, 800.0])
.with_min_inner_size([800.0, 600.0]),
..Default::default()
};
eframe::run_native(
"MarkPrompter",
options,
Box::new(|cc| Box::new(MarkPrompter::new(cc)))
)
}
neiam
/markprompter
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
22.1 KiB · text
History
390c837