//! 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, Grammar and Spelling rows — and the /// status bar along the bottom, which is rendered here too. /// /// 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("📂").on_hover_text("Browse for workspace folder").clicked() { self.browse_workspace(); } 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 { 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() { 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(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(); } // Resolved first: the hover text borrows the path, and the click // handler needs `self` mutably. let project = self.project_root().display().to_string(); if ui .button("📁") .on_hover_text(format!("Open the project folder {project}")) .clicked() { self.open_project_folder(); } ui.separator(); if ui .checkbox(&mut self.config.show_preview, "Preview") .changed() { self.config.save(); } ui.separator(); ui.label("Draft marker:") .on_hover_text( "Text above this line in each file (its header) is dropped on export; \ # Title: / # Slug: lines become the chapter heading and caption. \ Leave blank to export files whole.", ); let marker_resp = ui.add( egui::TextEdit::singleline(&mut self.config.draft_marker) .desired_width(140.0), ); if marker_resp.lost_focus() { self.config.save(); } ui.separator(); if ui .checkbox(&mut self.config.zero_pad_index, "Zero-pad #") .on_hover_text( "When a chapter title defaults to its number, pad it with \ leading zeros (e.g. 03. of 12).", ) .changed() { self.config.save(); } }); 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 ui .button("⚙") .on_hover_text("LanguageTool settings") .clicked() { self.show_settings = true; } if !self.lt_status.is_empty() { ui.label(egui::RichText::new(&self.lt_status).weak()); } }); ui.add_space(2.0); ui.horizontal(|ui| { ui.label("Spelling:").on_hover_text( "Offline spell check (Hunspell dictionaries). Underlines misspellings \ as you type when LanguageTool results aren't current; right-click a \ word for suggestions.", ); if ui .checkbox(&mut self.config.spell_check, "Check as I type") .on_hover_text("Underline misspellings live using the dictionary below") .changed() { self.config.save(); if self.config.spell_check { // Re-check the current buffer straight away. self.spell_dirty = true; self.spell_last_edit = None; } } ui.label("Dictionary:"); let current = self.config.spell_language.clone(); let mut pick: Option = None; egui::ComboBox::from_id_salt("spell_dict") .selected_text(current.clone()) .show_ui(ui, |ui| { for d in &self.spell_dicts { if ui .selectable_label(current == d.id, &d.label) .clicked() { pick = Some(d.id.clone()); } } }); if let Some(id) = pick { self.set_spell_language(&id); } if !self.spell_status.is_empty() { ui.label(egui::RichText::new(&self.spell_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)", ); // 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 cookiecutter 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(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("📖 Story Bible…").clicked() { ui.close_menu(); self.open_story_bible_editor(); } 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; } 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, Grammar and Spelling 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(); } 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…").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("Word list…").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, Spelling)") } else { ("⬇", "Show the toolbar (Workspace, Export, Grammar, Spelling)") }; 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 marker to fold at. if !collapsed && crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker) .is_none() { 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 the fields above the draft marker", ) } 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 = 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| { 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); }