Initial commit: md-manuscript editor
Rust/egui desktop app: draggable markdown file list, editor, git sync, per-file chapter titles, and native ODT export. Includes README and Debian 12 (Surface) install guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+736
@@ -0,0 +1,736 @@
|
||||
use crate::config::Config;
|
||||
use crate::gitsync;
|
||||
use crate::odt::{self, Chapter};
|
||||
use crate::order;
|
||||
use eframe::egui;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct App {
|
||||
config: Config,
|
||||
/// Ordered markdown file names (relative to the workspace).
|
||||
files: Vec<String>,
|
||||
/// Per-file chapter title overrides (file name -> title). Missing/empty means
|
||||
/// the title is derived automatically at export time.
|
||||
titles: HashMap<String, String>,
|
||||
/// Editing buffer for the selected file's chapter title override.
|
||||
title_input: String,
|
||||
selected: Option<usize>,
|
||||
/// Editor contents for the selected file.
|
||||
buffer: String,
|
||||
dirty: bool,
|
||||
/// Editable copy of the workspace path shown in the top bar.
|
||||
workspace_input: String,
|
||||
/// Editable copy of the export path.
|
||||
export_input: String,
|
||||
new_name: String,
|
||||
rename_input: String,
|
||||
pending_delete: bool,
|
||||
status: String,
|
||||
show_log: bool,
|
||||
git_log: String,
|
||||
is_repo: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let config = Config::load();
|
||||
let mut app = App {
|
||||
workspace_input: config.workspace.display().to_string(),
|
||||
export_input: config.export_path.display().to_string(),
|
||||
config,
|
||||
files: Vec::new(),
|
||||
titles: HashMap::new(),
|
||||
title_input: String::new(),
|
||||
selected: None,
|
||||
buffer: String::new(),
|
||||
dirty: false,
|
||||
new_name: String::new(),
|
||||
rename_input: String::new(),
|
||||
pending_delete: false,
|
||||
status: String::new(),
|
||||
show_log: false,
|
||||
git_log: String::new(),
|
||||
is_repo: false,
|
||||
};
|
||||
app.open_workspace();
|
||||
app
|
||||
}
|
||||
|
||||
fn workspace(&self) -> &Path {
|
||||
&self.config.workspace
|
||||
}
|
||||
|
||||
/// (Re)load the file list for the current workspace, creating the directory
|
||||
/// if needed, and refresh git status.
|
||||
fn open_workspace(&mut self) {
|
||||
let ws = self.config.workspace.clone();
|
||||
if let Err(e) = std::fs::create_dir_all(&ws) {
|
||||
self.status = format!("Cannot create workspace: {e}");
|
||||
return;
|
||||
}
|
||||
self.files = order::resolve_order(&ws);
|
||||
self.titles = order::read_titles(&ws);
|
||||
// Drop overrides for files that no longer exist.
|
||||
self.titles.retain(|name, _| self.files.contains(name));
|
||||
self.is_repo = gitsync::is_repo(&ws);
|
||||
self.selected = None;
|
||||
self.buffer.clear();
|
||||
self.dirty = false;
|
||||
self.pending_delete = false;
|
||||
if !self.files.is_empty() {
|
||||
self.select(0);
|
||||
}
|
||||
self.persist_order();
|
||||
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
|
||||
}
|
||||
|
||||
fn persist_order(&self) {
|
||||
let _ = order::write_order(self.workspace(), &self.files);
|
||||
}
|
||||
|
||||
fn persist_titles(&self) {
|
||||
let _ = order::write_titles(self.workspace(), &self.titles);
|
||||
}
|
||||
|
||||
/// Store the title override for the selected file from `title_input`.
|
||||
/// An empty value removes the override (falls back to the auto title).
|
||||
fn set_title_for_current(&mut self) {
|
||||
if let Some(idx) = self.selected {
|
||||
if let Some(name) = self.files.get(idx).cloned() {
|
||||
let trimmed = self.title_input.trim();
|
||||
if trimmed.is_empty() {
|
||||
self.titles.remove(&name);
|
||||
} else {
|
||||
self.titles.insert(name, trimmed.to_string());
|
||||
}
|
||||
self.persist_titles();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_for(&self, name: &str) -> PathBuf {
|
||||
self.workspace().join(name)
|
||||
}
|
||||
|
||||
/// Save the in-memory buffer to disk if it has unsaved changes.
|
||||
fn save_current(&mut self) {
|
||||
if let Some(idx) = self.selected {
|
||||
if self.dirty {
|
||||
if let Some(name) = self.files.get(idx) {
|
||||
let path = self.path_for(name);
|
||||
match std::fs::write(&path, &self.buffer) {
|
||||
Ok(_) => {
|
||||
self.dirty = false;
|
||||
self.status = format!("Saved {name}");
|
||||
}
|
||||
Err(e) => self.status = format!("Save failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, idx: usize) {
|
||||
if self.selected == Some(idx) {
|
||||
return;
|
||||
}
|
||||
self.save_current();
|
||||
if let Some(name) = self.files.get(idx) {
|
||||
let path = self.path_for(name);
|
||||
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
self.selected = Some(idx);
|
||||
self.dirty = false;
|
||||
self.pending_delete = false;
|
||||
self.rename_input = Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
self.title_input = self.titles.get(name).cloned().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file(&mut self) {
|
||||
let mut stem = self.new_name.trim().to_string();
|
||||
if stem.is_empty() {
|
||||
self.status = "Enter a name for the new file".to_string();
|
||||
return;
|
||||
}
|
||||
if stem.to_lowercase().ends_with(".md") {
|
||||
stem.truncate(stem.len() - 3);
|
||||
}
|
||||
let name = format!("{stem}.md");
|
||||
let path = self.path_for(&name);
|
||||
if path.exists() {
|
||||
self.status = format!("{name} already exists");
|
||||
return;
|
||||
}
|
||||
let seed = format!("# {stem}\n\n");
|
||||
match std::fs::write(&path, seed) {
|
||||
Ok(_) => {
|
||||
self.files.push(name.clone());
|
||||
self.persist_order();
|
||||
let idx = self.files.len() - 1;
|
||||
self.selected = None; // force reload of buffer
|
||||
self.select(idx);
|
||||
self.new_name.clear();
|
||||
self.status = format!("Created {name}");
|
||||
}
|
||||
Err(e) => self.status = format!("Create failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_selected(&mut self) {
|
||||
if let Some(idx) = self.selected {
|
||||
if let Some(name) = self.files.get(idx).cloned() {
|
||||
let path = self.path_for(&name);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(_) => {
|
||||
self.files.remove(idx);
|
||||
self.titles.remove(&name);
|
||||
self.persist_titles();
|
||||
self.persist_order();
|
||||
self.selected = None;
|
||||
self.buffer.clear();
|
||||
self.dirty = false;
|
||||
if !self.files.is_empty() {
|
||||
self.select(idx.min(self.files.len() - 1));
|
||||
}
|
||||
self.status = format!("Deleted {name}");
|
||||
}
|
||||
Err(e) => self.status = format!("Delete failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pending_delete = false;
|
||||
}
|
||||
|
||||
fn rename_selected(&mut self) {
|
||||
let Some(idx) = self.selected else { return };
|
||||
let mut stem = self.rename_input.trim().to_string();
|
||||
if stem.to_lowercase().ends_with(".md") {
|
||||
stem.truncate(stem.len() - 3);
|
||||
}
|
||||
if stem.is_empty() {
|
||||
self.status = "Enter a new name".to_string();
|
||||
return;
|
||||
}
|
||||
let new_name = format!("{stem}.md");
|
||||
let Some(old_name) = self.files.get(idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
if new_name == old_name {
|
||||
return;
|
||||
}
|
||||
let new_path = self.path_for(&new_name);
|
||||
if new_path.exists() {
|
||||
self.status = format!("{new_name} already exists");
|
||||
return;
|
||||
}
|
||||
// Persist any pending edits under the old name first.
|
||||
self.save_current();
|
||||
match std::fs::rename(self.path_for(&old_name), &new_path) {
|
||||
Ok(_) => {
|
||||
self.files[idx] = new_name.clone();
|
||||
if let Some(title) = self.titles.remove(&old_name) {
|
||||
self.titles.insert(new_name.clone(), title);
|
||||
self.persist_titles();
|
||||
}
|
||||
self.persist_order();
|
||||
self.status = format!("Renamed to {new_name}");
|
||||
}
|
||||
Err(e) => self.status = format!("Rename failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn git_init(&mut self) {
|
||||
let outcome = gitsync::init(self.workspace());
|
||||
self.git_log = outcome.log;
|
||||
self.show_log = true;
|
||||
self.is_repo = gitsync::is_repo(self.workspace());
|
||||
self.status = if self.is_repo {
|
||||
"Initialised git repository".to_string()
|
||||
} else {
|
||||
"git init failed (see log)".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn git_sync(&mut self) {
|
||||
self.save_current();
|
||||
self.persist_order();
|
||||
self.persist_titles();
|
||||
let msg = format!(
|
||||
"Sync manuscript {}",
|
||||
chrono_like_timestamp()
|
||||
);
|
||||
let outcome = gitsync::sync(self.workspace(), &msg);
|
||||
self.git_log = outcome.log;
|
||||
self.show_log = true;
|
||||
self.status = if outcome.ok {
|
||||
"Sync complete".to_string()
|
||||
} else {
|
||||
"Sync finished with errors (see log)".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
fn export_odt(&mut self) {
|
||||
self.save_current();
|
||||
let mut chapters = Vec::new();
|
||||
for name in &self.files {
|
||||
let markdown = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||
// The leading `# heading` is always consumed as the chapter-title
|
||||
// slot; a manual override, if set, replaces the derived title.
|
||||
let (auto_title, body) = split_title(&markdown, name);
|
||||
let title = self
|
||||
.titles
|
||||
.get(name)
|
||||
.map(|t| t.trim())
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or(auto_title);
|
||||
chapters.push(Chapter {
|
||||
title,
|
||||
markdown: body,
|
||||
});
|
||||
}
|
||||
let out = PathBuf::from(self.export_input.trim());
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match odt::export(&chapters, &out) {
|
||||
Ok(_) => {
|
||||
self.config.export_path = out.clone();
|
||||
self.config.save();
|
||||
self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display());
|
||||
}
|
||||
Err(e) => self.status = format!("Export failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn reorder(&mut self, from: usize, mut to: usize) {
|
||||
if from >= self.files.len() || from == to {
|
||||
return;
|
||||
}
|
||||
// Remember the selected file by name so selection follows the move.
|
||||
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
|
||||
|
||||
let item = self.files.remove(from);
|
||||
if from < to {
|
||||
to -= 1;
|
||||
}
|
||||
to = to.min(self.files.len());
|
||||
self.files.insert(to, item);
|
||||
self.persist_order();
|
||||
|
||||
if let Some(name) = selected_name {
|
||||
self.selected = self.files.iter().position(|n| *n == name);
|
||||
}
|
||||
self.status = "Reordered".to_string();
|
||||
}
|
||||
|
||||
// ---- UI ----------------------------------------------------------------
|
||||
|
||||
fn top_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("top").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Workspace:");
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.workspace_input)
|
||||
.desired_width(360.0),
|
||||
);
|
||||
if ui.button("Open").clicked()
|
||||
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
{
|
||||
self.save_current();
|
||||
self.config.workspace = PathBuf::from(self.workspace_input.trim());
|
||||
self.config.save();
|
||||
self.open_workspace();
|
||||
}
|
||||
ui.separator();
|
||||
if self.is_repo {
|
||||
if ui.button("⟳ Sync (git)").clicked() {
|
||||
self.git_sync();
|
||||
}
|
||||
} else if ui.button("Init git").clicked() {
|
||||
self.git_init();
|
||||
}
|
||||
if ui.button("Log").clicked() {
|
||||
self.show_log = !self.show_log;
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Export:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.export_input).desired_width(360.0),
|
||||
);
|
||||
if ui.button("Export ODT").clicked() {
|
||||
self.export_odt();
|
||||
}
|
||||
ui.separator();
|
||||
if ui
|
||||
.checkbox(&mut self.config.show_preview, "Preview")
|
||||
.changed()
|
||||
{
|
||||
self.config.save();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
let dirty = if self.dirty { " • unsaved" } else { "" };
|
||||
ui.label(format!("{}{dirty}", self.status));
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
});
|
||||
}
|
||||
|
||||
fn left_pane(&mut self, ctx: &egui::Context) {
|
||||
egui::SidePanel::left("files")
|
||||
.resizable(true)
|
||||
.default_width(260.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.heading("Files");
|
||||
ui.label(
|
||||
egui::RichText::new("drag ⠿ to reorder")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
let mut clicked: Option<usize> = None;
|
||||
let mut from_to: Option<(usize, usize)> = None;
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.max_height(ui.available_height() - 120.0)
|
||||
.show(ui, |ui| {
|
||||
for idx in 0..self.files.len() {
|
||||
let name = self.files[idx].clone();
|
||||
let selected = self.selected == Some(idx);
|
||||
let row = ui
|
||||
.horizontal(|ui| {
|
||||
ui.dnd_drag_source(
|
||||
egui::Id::new(("dnd", &name)),
|
||||
idx,
|
||||
|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("⠿").monospace().weak(),
|
||||
);
|
||||
},
|
||||
);
|
||||
if ui
|
||||
.add_sized(
|
||||
[ui.available_width(), 20.0],
|
||||
egui::SelectableLabel::new(selected, &name),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
clicked = Some(idx);
|
||||
}
|
||||
})
|
||||
.response;
|
||||
|
||||
// Drop handling: is a dragged item hovering this row?
|
||||
if let Some(_payload) = row.dnd_hover_payload::<usize>() {
|
||||
let rect = row.rect;
|
||||
let before = pointer
|
||||
.map(|p| p.y < rect.center().y)
|
||||
.unwrap_or(true);
|
||||
let y = if before { rect.top() } else { rect.bottom() };
|
||||
ui.painter().hline(
|
||||
rect.x_range(),
|
||||
y,
|
||||
egui::Stroke::new(
|
||||
2.0,
|
||||
ui.visuals().selection.stroke.color,
|
||||
),
|
||||
);
|
||||
if let Some(payload) = row.dnd_release_payload::<usize>() {
|
||||
let target = if before { idx } else { idx + 1 };
|
||||
from_to = Some((*payload, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(idx) = clicked {
|
||||
self.select(idx);
|
||||
}
|
||||
if let Some((from, to)) = from_to {
|
||||
self.reorder(from, to);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.new_name)
|
||||
.hint_text("new file name")
|
||||
.desired_width(150.0),
|
||||
);
|
||||
if ui.button("+ New").clicked() {
|
||||
self.create_file();
|
||||
}
|
||||
});
|
||||
|
||||
if self.selected.is_some() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.rename_input)
|
||||
.hint_text("rename")
|
||||
.desired_width(150.0),
|
||||
);
|
||||
if ui.button("Rename").clicked() {
|
||||
self.rename_selected();
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if !self.pending_delete {
|
||||
if ui.button("🗑 Delete").clicked() {
|
||||
self.pending_delete = true;
|
||||
}
|
||||
} else {
|
||||
ui.label("Delete file?");
|
||||
if ui
|
||||
.button(egui::RichText::new("Yes").color(egui::Color32::RED))
|
||||
.clicked()
|
||||
{
|
||||
self.delete_selected();
|
||||
}
|
||||
if ui.button("No").clicked() {
|
||||
self.pending_delete = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn central(&mut self, ctx: &egui::Context) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
match self.selected {
|
||||
Some(idx) => {
|
||||
let name = self.files[idx].clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&name);
|
||||
if ui.button("💾 Save").clicked() {
|
||||
self.dirty = true; // ensure save runs
|
||||
self.save_current();
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Chapter title:");
|
||||
let auto = split_title(&self.buffer, &name).0;
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.title_input)
|
||||
.hint_text(format!("auto: {auto}"))
|
||||
.desired_width(320.0),
|
||||
);
|
||||
if resp.changed() {
|
||||
self.set_title_for_current();
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new("used as the ODT chapter heading")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
if self.config.show_preview {
|
||||
// Editor + read-only source-ish preview side by side.
|
||||
let full = ui.available_size();
|
||||
ui.horizontal_top(|ui| {
|
||||
let col_w = full.x / 2.0 - 6.0;
|
||||
ui.allocate_ui(egui::vec2(col_w, full.y), |ui| {
|
||||
self.editor(ui);
|
||||
});
|
||||
ui.separator();
|
||||
ui.allocate_ui(egui::vec2(col_w, full.y), |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("preview")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
render_preview(ui, &self.buffer);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self.editor(ui);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label("Select a file on the left, or create a new one.");
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn editor(&mut self, ui: &mut egui::Ui) {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("editor")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::multiline(&mut self.buffer)
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(30),
|
||||
);
|
||||
if resp.changed() {
|
||||
self.dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Ctrl+S saves.
|
||||
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S)) {
|
||||
self.dirty = true;
|
||||
self.save_current();
|
||||
}
|
||||
|
||||
self.top_bar(ctx);
|
||||
self.left_pane(ctx);
|
||||
|
||||
if self.show_log {
|
||||
egui::TopBottomPanel::bottom("gitlog")
|
||||
.resizable(true)
|
||||
.default_height(160.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("git output").strong());
|
||||
if ui.button("Hide").clicked() {
|
||||
self.show_log = false;
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut self.git_log.as_str())
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
self.central(ctx);
|
||||
}
|
||||
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
self.save_current();
|
||||
self.persist_order();
|
||||
self.persist_titles();
|
||||
self.config.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// Very small markdown-ish preview (headings emphasised, everything else plain).
|
||||
fn render_preview(ui: &mut egui::Ui, markdown: &str) {
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
let parser = Parser::new_ext(markdown, Options::ENABLE_STRIKETHROUGH);
|
||||
let mut heading: Option<HeadingLevel> = None;
|
||||
let mut line = String::new();
|
||||
let mut bold = false;
|
||||
let mut italic = false;
|
||||
|
||||
let flush = |ui: &mut egui::Ui, line: &mut String, heading: &mut Option<HeadingLevel>| {
|
||||
if line.trim().is_empty() {
|
||||
line.clear();
|
||||
*heading = None;
|
||||
return;
|
||||
}
|
||||
let text = line.clone();
|
||||
match heading {
|
||||
Some(HeadingLevel::H1) => {
|
||||
ui.label(egui::RichText::new(text).size(22.0).strong());
|
||||
}
|
||||
Some(HeadingLevel::H2) => {
|
||||
ui.label(egui::RichText::new(text).size(18.0).strong());
|
||||
}
|
||||
Some(_) => {
|
||||
ui.label(egui::RichText::new(text).size(15.0).strong());
|
||||
}
|
||||
None => {
|
||||
ui.label(text);
|
||||
}
|
||||
}
|
||||
line.clear();
|
||||
*heading = None;
|
||||
};
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => heading = Some(level),
|
||||
Event::End(TagEnd::Heading(_)) => flush(ui, &mut line, &mut heading),
|
||||
Event::End(TagEnd::Paragraph) => flush(ui, &mut line, &mut heading),
|
||||
Event::Start(Tag::Item) => line.push_str("• "),
|
||||
Event::End(TagEnd::Item) => flush(ui, &mut line, &mut heading),
|
||||
Event::Start(Tag::Strong) => bold = true,
|
||||
Event::End(TagEnd::Strong) => bold = false,
|
||||
Event::Start(Tag::Emphasis) => italic = true,
|
||||
Event::End(TagEnd::Emphasis) => italic = false,
|
||||
Event::Text(t) | Event::Code(t) => {
|
||||
let _ = (bold, italic);
|
||||
line.push_str(&t);
|
||||
}
|
||||
Event::SoftBreak | Event::HardBreak => line.push(' '),
|
||||
Event::Rule => {
|
||||
ui.separator();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(ui, &mut line, &mut heading);
|
||||
}
|
||||
|
||||
/// Extract the chapter title from a markdown document: use the first level-1
|
||||
/// heading if the document starts with one (and drop it from the body so it is
|
||||
/// not duplicated), otherwise fall back to the file name stem.
|
||||
fn split_title(markdown: &str, filename: &str) -> (String, String) {
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(filename)
|
||||
.to_string();
|
||||
|
||||
let lines: Vec<&str> = markdown.lines().collect();
|
||||
let mut idx = 0;
|
||||
while idx < lines.len() && lines[idx].trim().is_empty() {
|
||||
idx += 1;
|
||||
}
|
||||
if idx < lines.len() {
|
||||
let l = lines[idx].trim_start();
|
||||
if let Some(rest) = l.strip_prefix("# ") {
|
||||
let title = rest.trim().to_string();
|
||||
let mut body_lines: Vec<&str> = Vec::new();
|
||||
body_lines.extend_from_slice(&lines[..idx]);
|
||||
body_lines.extend_from_slice(&lines[idx + 1..]);
|
||||
return (title, body_lines.join("\n"));
|
||||
}
|
||||
}
|
||||
(stem, markdown.to_string())
|
||||
}
|
||||
|
||||
/// A dependency-free timestamp for commit messages (UTC seconds since epoch).
|
||||
fn chrono_like_timestamp() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("@{secs}")
|
||||
}
|
||||
Reference in New Issue
Block a user