Files
md-manuscript/src/app/ui.rs
T
landon 6ca8681d08 Add workspace search, synopsis view, snapshot diffs, typewriter mode, and a writing streak
Five productivity features: search/replace across the whole workspace
(with a safety commit before Replace all), a Synopsis window compiling
every chapter's slug, a Diff button per Auto-Snapshot to compare against
an older snapshot instead of only HEAD, typewriter-style cursor centering
and paragraph dimming gated on Distraction-free mode, and a per-day
word-count streak persisted to word_log.json alongside order.json/titles.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 15:55:15 -05:00

712 lines
31 KiB
Rust

//! Top-level window chrome: the menu bar, the toolbar, the central editor
//! panel, the markdown preview and the repo-adoption prompt.
use super::*;
impl App {
// ---- UI ----------------------------------------------------------------
/// The toolbar — the Workspace, Export and Grammar rows — and the status
/// bar along the bottom, which is rendered here too.
///
/// Anything with a menu equivalent (opening the workspace, syncing git,
/// toggling a panel, LanguageTool settings, …) lives only in the menu bar;
/// this panel keeps just the controls that need a live value at hand —
/// the workspace/export paths and the grammar-check trigger — so hiding it
/// (View ▸ Toolbar, or Ctrl+D) never costs you a feature, only convenience.
///
/// The toolbar collapses through `show_animated`, which reserves no height
/// when hidden and slides rather than snapping. Note that it guards only the
/// top panel: the status bar shares this function and must keep drawing.
pub(super) fn top_bar(&mut self, ctx: &egui::Context) {
let show_toolbar = self.config.show_toolbar;
egui::TopBottomPanel::top("top").show_animated(ctx, show_toolbar, |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(300.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.add_space(2.0);
ui.horizontal(|ui| {
ui.label("Export:");
ui.add(
egui::TextEdit::singleline(&mut self.export_input).desired_width(300.0),
);
let screenplay = self.config.manuscript_screenplay;
let browse_hint = if screenplay {
"Browse for export .pdf file"
} else {
"Browse for export .odt file"
};
if ui.button("📂").on_hover_text(browse_hint).clicked() {
self.browse_export();
}
let export_hint = if screenplay {
"Lay the script out in screenplay format and write it as a PDF"
} else {
"Write the manuscript as a single OpenDocument file"
};
if ui
.button(self.export_label())
.on_hover_text(export_hint)
.clicked()
{
self.export_manuscript();
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.label("Grammar:").on_hover_text(
"Grammar & spelling via a LanguageTool server. \
Configure it in Settings ▸ LanguageTool.",
);
let checking = self.lt_rx.is_some();
if ui
.add_enabled(
!checking && self.selected.is_some(),
egui::Button::new("✓ Check"),
)
.on_hover_text("Check the current file's grammar and spelling")
.clicked()
{
self.start_lt_check(ctx);
}
if !self.lt_matches.is_empty()
&& ui.button("Clear").on_hover_text("Clear check results").clicked()
{
self.clear_lt();
}
if !self.lt_status.is_empty() {
ui.label(egui::RichText::new(&self.lt_status).weak());
}
});
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));
let goal = self.current_goal();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(idx) = self.selected {
let name = &self.files[idx];
let total = count_words(&self.document());
let baseline =
self.session_start_counts.get(name).copied().unwrap_or(0);
let delta = total as i64 - baseline as i64;
let sign = if delta < 0 { "-" } else { "+" };
ui.label(
egui::RichText::new(format!(
"{} words · {sign}{} this session",
thousands(total),
thousands(delta.unsigned_abs() as usize),
))
.weak(),
)
.on_hover_text(
"Words in the current file · net change since this session opened",
);
// The whole book's length, which is the number that
// actually tells you where the manuscript stands.
ui.separator();
ui.label(
egui::RichText::new(format!(
"{} total",
thousands(self.manuscript_words())
))
.weak(),
)
.on_hover_text(
"Prose words across the whole manuscript (reference \
files are not counted)",
);
// A streak of consecutive days with words added,
// persisted in word_log.json so it survives restarts.
let streak = self.word_log.streak(crate::word_log::today());
if streak > 1 {
ui.separator();
ui.label(egui::RichText::new(format!("🔥 {streak}-day streak")).weak())
.on_hover_text(
"Consecutive days the manuscript's word count has grown",
);
}
// Progress toward the file's Word Count Target, if set.
if let Some((goal, prose)) = goal {
let (frac, color, text) = goal_progress(goal, prose);
ui.separator();
ui.add(
egui::ProgressBar::new(frac)
.desired_width(150.0)
.fill(color)
.text(egui::RichText::new(text).small()),
)
.on_hover_text(
"Prose words vs the file's “Word Count Target” header \
(counts the body below the draft marker). Amber = under \
target, green = in range, blue = over.",
);
}
}
});
});
ui.add_space(2.0);
});
}
/// The application menu bar (File / View / Settings).
pub(super) fn menu_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("menubar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui
.button("✨ New project…")
.on_hover_text(
"Scaffold a project from the bundled template and open \
its drafting folder",
)
.clicked()
{
ui.close_menu();
self.open_new_project();
}
ui.separator();
if ui.button("📂 Open workspace…").clicked() {
ui.close_menu();
self.browse_workspace();
}
if ui.button("📁 Open project folder").clicked() {
ui.close_menu();
self.open_project_folder();
}
if self.is_repo {
let hover = match &self.repo_root {
Some(root) if root.as_path() != self.workspace() => {
format!("Commit & push to the repository at {}", root.display())
}
_ => "Commit & push this workspace's repository".to_string(),
};
if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() {
ui.close_menu();
self.git_sync();
}
} else if ui.button("Init git").clicked() {
ui.close_menu();
self.git_init();
}
ui.separator();
if ui.button(self.export_label()).clicked() {
ui.close_menu();
self.export_manuscript();
}
// The master export assembles chapters, which a screenplay
// does not have: its scenes are one continuous script.
if !self.config.manuscript_screenplay
&& ui
.button("Export chapters + master (.odm)")
.on_hover_text(
"One .odt per chapter in a chapters/ folder, plus an \
.odm master that links them",
)
.clicked()
{
ui.close_menu();
self.export_master();
}
ui.separator();
if ui.button("Quit").clicked() {
ui.close_menu();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
ui.menu_button("Edit", |ui| {
if ui
.button("⇄ Changes since last commit…")
.on_hover_text("Diff the open file against its committed version")
.clicked()
{
ui.close_menu();
self.open_diff();
}
ui.separator();
if ui.button("🔍 Find / Replace…").clicked() {
ui.close_menu();
self.open_find(true);
}
if ui
.button("🔎 Search workspace… Ctrl+Shift+F")
.on_hover_text("Find (and optionally replace) across every file")
.clicked()
{
ui.close_menu();
self.open_workspace_search();
}
if ui.button("📸 Snapshots…").clicked() {
ui.close_menu();
self.show_snapshots = true;
}
});
ui.menu_button("Tools", |ui| {
if ui.button("☑ Outline…").clicked() {
ui.close_menu();
self.show_outline = true;
}
if ui.button("👥 Characters…").clicked() {
ui.close_menu();
self.show_characters = true;
}
if ui.button("📖 Story Bible…").clicked() {
ui.close_menu();
self.show_story_bible = true;
}
if ui.button("📖 Story Bible editor…").clicked() {
ui.close_menu();
self.open_story_bible_editor();
}
if ui.button("🧾 Synopsis…").clicked() {
ui.close_menu();
self.show_synopsis = true;
}
ui.separator();
let busy = self.beats_rx.is_some();
if ui
.add_enabled(
!busy,
egui::Button::new("✨ Generate plot beats (Mistral)…"),
)
.clicked()
{
ui.close_menu();
self.generate_plot_beats(ctx);
}
});
ui.menu_button("View", |ui| {
if ui
.checkbox(&mut self.config.show_toolbar, "Toolbar")
.on_hover_text(
"The Workspace, Export and Grammar rows \
under the menu bar",
)
.clicked()
{
self.config.save();
}
if ui
.checkbox(&mut self.config.show_file_panel, "File list")
.on_hover_text("The file tree down the left-hand side")
.clicked()
{
self.config.save();
}
if ui
.button("🖹 Distraction-free Ctrl+D")
.on_hover_text(
"Fold away the toolbar and the file list together, \
leaving nothing but the page. Again to bring them back.",
)
.clicked()
{
ui.close_menu();
self.toggle_distraction_free();
}
if ui
.checkbox(&mut self.config.typewriter_mode, "Typewriter scrolling & focus")
.on_hover_text(
"While Distraction-free mode is on, keep the caret's line \
centered and dim every paragraph but the current one. \
Has no effect otherwise.",
)
.changed()
{
self.config.save();
}
ui.separator();
if ui
.checkbox(&mut self.config.show_preview, "Preview pane")
.clicked()
{
self.config.save();
}
if ui
.checkbox(
&mut self.config.show_reference_files,
"Show reference files",
)
.on_hover_text(
"List a project's characters, outline and scratch pad in \
the file panel alongside the manuscript",
)
.clicked()
{
self.config.save();
}
if ui
.checkbox(&mut self.config.collapse_header, "Hide header in editor")
.on_hover_text(
"Edit the prose alone, with the fields above the draft \
marker folded away",
)
.clicked()
{
self.apply_header_collapse();
self.config.save();
}
ui.checkbox(&mut self.show_lt_panel, "Grammar panel");
ui.checkbox(&mut self.show_log, "Git log");
});
ui.menu_button("Settings", |ui| {
if ui.button("LanguageTool…").clicked() {
ui.close_menu();
self.show_settings = true;
}
if ui.button("Mistral…").clicked() {
ui.close_menu();
self.show_mistral_settings = true;
}
if ui.button("New-file template…").clicked() {
ui.close_menu();
self.show_template_settings = true;
}
if ui.button("New-project settings…").clicked() {
ui.close_menu();
self.show_project_settings = true;
}
if ui.button("Git remote…").clicked() {
ui.close_menu();
self.open_git_remote();
}
if ui.button("Manuscript details…").clicked() {
ui.close_menu();
self.show_manuscript_settings = true;
}
if ui.button("Spelling…").clicked() {
ui.close_menu();
self.show_wordlist = true;
}
});
ui.menu_button("Help", |ui| {
if ui.button("📝 Markdown cheatsheet").clicked() {
ui.close_menu();
self.show_cheatsheet = true;
}
});
// Right-aligned, and in the menu bar rather than the toolbar:
// a control that hides the toolbar cannot live inside it.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (icon, hint) = if self.config.show_toolbar {
("⬆", "Hide the toolbar (Workspace, Export, Grammar)")
} else {
("⬇", "Show the toolbar (Workspace, Export, Grammar)")
};
if ui.button(icon).on_hover_text(hint).clicked() {
self.config.show_toolbar = !self.config.show_toolbar;
self.config.save();
}
// Added second, so in a right-to-left layout it sits to the
// left of the toolbar toggle -- the side its panel is on.
let (icon, hint) = if self.config.show_file_panel {
("⬅", "Hide the file list")
} else {
("➡", "Show the file list")
};
if ui.button(icon).on_hover_text(hint).clicked() {
self.config.show_file_panel = !self.config.show_file_panel;
self.config.save();
}
});
});
});
}
pub(super) 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.separator();
ui.label("Zoom:");
if ui
.add(
egui::Slider::new(&mut self.config.editor_zoom, -50.0..=200.0)
.suffix("%")
.step_by(5.0),
)
.on_hover_text("Editor text size (0% = default)")
.changed()
{
self.config.save();
}
if ui
.button("Reset")
.on_hover_text("Reset zoom to 0%")
.clicked()
{
self.config.editor_zoom = 0.0;
self.config.save();
}
ui.separator();
ui.label("Contrast:");
if ui
.add(
egui::Slider::new(
&mut self.config.editor_text_contrast,
0.0..=100.0,
)
.suffix("%")
.step_by(5.0),
)
.on_hover_text(
"Editor text contrast (0% = theme default; higher \
brightens the text in dark mode)",
)
.changed()
{
self.config.save();
}
});
ui.horizontal(|ui| {
ui.label("Chapter title:");
let auto = self.auto_title(idx, &self.document());
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(),
);
});
self.header_toggle(ui);
ui.separator();
if self.show_find {
self.find_bar(ui);
}
if self.config.show_preview {
// Editor + read-only source-ish preview side by side.
let full = ui.available_size();
// Both columns need an explicit top-down layout: a child
// `Ui` inherits its parent's, and inside `horizontal_top`
// that is left-to-right, which would run every paragraph
// of the preview onto one line.
let down = egui::Layout::top_down(egui::Align::Min);
ui.horizontal_top(|ui| {
let col_w = full.x / 2.0 - 6.0;
ui.allocate_ui_with_layout(egui::vec2(col_w, full.y), down, |ui| {
self.editor(ui);
});
ui.separator();
ui.allocate_ui_with_layout(egui::vec2(col_w, full.y), down, |ui| {
egui::ScrollArea::vertical()
.id_salt("preview")
.auto_shrink([false, false])
.show(ui, |ui| {
render_preview(ui, &self.buffer);
});
});
});
} else {
self.editor(ui);
}
// Check for auto-snapshot after editor updates
self.check_auto_snapshot();
}
None => {
ui.centered_and_justified(|ui| {
ui.label("Select a file on the left, or create a new one.");
});
}
}
});
}
/// Ask the user whether to adopt a git repository found in a parent
/// directory of the freshly opened workspace.
pub(super) fn repo_prompt(&mut self, ctx: &egui::Context) {
let Some(root) = self.pending_repo.clone() else {
return;
};
let mut adopt = false;
let mut decline = false;
egui::Window::new("Use enclosing git repository?")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(
"This folder isn't a git repository, but one was found in a \
parent directory:",
);
ui.add_space(4.0);
ui.label(egui::RichText::new(root.display().to_string()).strong());
ui.add_space(4.0);
ui.label(
"Use it for version control (Sync commits and pushes to this \
repo)? Otherwise the workspace is left without git; you can \
still Init a separate repository here.",
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Use this repository").clicked() {
adopt = true;
}
if ui.button("No, keep separate").clicked() {
decline = true;
}
});
});
if adopt {
self.adopt_pending_repo();
} else if decline {
self.decline_pending_repo();
}
}
/// A single line saying whether the editorial header is folded away, and
/// letting you flip it.
///
/// It exists so a hidden header can never be mistaken for a file that has
/// none: the editor is showing less than the file holds, and that has to be
/// visible without opening a menu.
/// Fold away everything that is not the page — the toolbar and the file
/// list — and put both back on the next invocation.
///
/// One command in and out, rather than a mode with a state of its own: if
/// any chrome is showing, hide it all; otherwise restore it all.
pub(super) fn toggle_distraction_free(&mut self) {
let any_chrome = self.config.show_toolbar || self.config.show_file_panel;
self.config.show_toolbar = !any_chrome;
self.config.show_file_panel = !any_chrome;
self.config.save();
self.status = if any_chrome {
"Distraction-free — Ctrl+D to bring the panels back".to_string()
} else {
"Panels restored".to_string()
};
}
pub(super) fn header_toggle(&mut self, ui: &mut egui::Ui) {
let collapsed = self.header_stash.is_some();
// Nothing to say about a file with no header to fold away. Both forms
// count: asking only about the draft marker meant a frontmatter file
// never offered the toggle, because it has no marker to find — the fold
// itself has handled frontmatter all along.
if !collapsed && !self.header_is_foldable() {
return;
}
ui.horizontal(|ui| {
let (label, hover) = if collapsed {
let n = self.hidden_field_count();
(
format!("▸ header hidden ({n} field{})", if n == 1 { "" } else { "s" }),
"Show this file's fields again",
)
} else {
(
"▾ header shown".to_string(),
"Fold the fields away and edit the prose alone",
)
};
if ui
.add(egui::Button::new(egui::RichText::new(label).small().weak()).frame(false))
.on_hover_text(hover)
.clicked()
{
self.config.collapse_header = !collapsed;
self.apply_header_collapse();
self.config.save();
}
});
}
}
/// Very small markdown-ish preview (headings emphasised, everything else plain).
pub(super) 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);
}