753 lines
33 KiB
Rust
753 lines
33 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",
|
|
);
|
|
}
|
|
|
|
// A sprint countdown, but only in Distraction-free mode —
|
|
// outside it, the full detail lives in the Sprint window.
|
|
let distraction_free =
|
|
!self.config.show_toolbar && !self.config.show_file_panel;
|
|
if distraction_free {
|
|
if let Some(left) = self.sprint_remaining() {
|
|
let secs = left.as_secs();
|
|
ui.separator();
|
|
ui.label(
|
|
egui::RichText::new(format!(
|
|
"⏱ {:02}:{:02}",
|
|
secs / 60,
|
|
secs % 60
|
|
))
|
|
.weak(),
|
|
)
|
|
.on_hover_text("Time left in the current writing sprint");
|
|
ctx.request_repaint_after(Duration::from_millis(500));
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
if ui.button("⏱ Sprint…").clicked() {
|
|
ui.close_menu();
|
|
self.show_sprint = true;
|
|
}
|
|
if ui.button("🔍 Placeholders…").clicked() {
|
|
ui.close_menu();
|
|
self.show_placeholders = true;
|
|
}
|
|
if ui.button("🗂 Corkboard…").clicked() {
|
|
ui.close_menu();
|
|
self.show_corkboard = true;
|
|
}
|
|
if ui.button("📥 Quick note… Ctrl+Shift+N").clicked() {
|
|
ui.close_menu();
|
|
self.show_quick_capture = 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("📖 User Guide").clicked() {
|
|
ui.close_menu();
|
|
self.show_user_guide = true;
|
|
}
|
|
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);
|
|
}
|