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:
landon
2026-07-25 15:17:42 -05:00
commit f473b63ee3
11 changed files with 6201 additions and 0 deletions
+736
View File
@@ -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}")
}