Add title pages, standard manuscript format and collapsible panels
Five changes to the export and the window chrome: * Manuscript details gains a contact field and a "Begin exports with a title page" option: the title, the author beneath it, and the contact details beneath that, laid out line for line as typed. A chapters + master export puts the title page on the master, not on each chapter. * "Standard manuscript format" lays an export out the way an agent or an editor expects a submission: 12pt Courier, double-spaced, half-inch first-line indents, a Surname / Title / page header on every page but the title page, chapters opening a third of the way down, and `---` rendered as the conventional centred `#` scene break. The title page becomes the submission kind, with contact top-left and an approximate word count top-right. Off by default; margins were already the standard 1in on US Letter and are unchanged either way. * A folder button beside Export ODT opens the project folder in the file manager -- the enclosing git work tree, including one still awaiting confirmation, since showing a folder is a smaller question than choosing which repository to commit to. * The toolbar and the file list each collapse, from the pair of buttons at the right of the menu bar or from the View menu, and Ctrl+D folds both away together for distraction-free writing. Both choices persist. * The editor sizes to its viewport instead of a fixed 30 rows, so the height a folded panel gives back reaches the page rather than leaving grey space below the text box that did not even take focus. Two traps worth recording. ODF's style:master-page-name is inherited, and any paragraph style carrying one forces a page break before every paragraph that uses it -- a four-line contact block came out as four pages until Contact_20_Line stopped inheriting from Contact_20_Block; a test now pins which styles may carry one. And the status bar shares a function with the toolbar, so guarding that function's top rather than the top panel alone silently took the word counts away with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYSGPDwkSzhm4qLqjCbqxU
This commit is contained in:
+10
-1
@@ -82,11 +82,20 @@ impl App {
|
||||
self.apply_field_completion(ui.ctx(), &field);
|
||||
}
|
||||
|
||||
// Fill the viewport rather than a fixed number of rows, so the
|
||||
// height handed back by a folded toolbar or file list reaches
|
||||
// the page instead of leaving grey space under the editor.
|
||||
// Measured against the *unzoomed* row height because that is
|
||||
// what `desired_rows` is multiplied by, whatever the layouter
|
||||
// then draws at.
|
||||
let row_h = ui.text_style_height(&egui::TextStyle::Monospace).max(1.0);
|
||||
let rows = (ui.available_height() / row_h).floor().max(10.0) as usize;
|
||||
|
||||
let output = egui::TextEdit::multiline(&mut self.buffer)
|
||||
.id(egui::Id::new(EDITOR_ID))
|
||||
.code_editor()
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(30)
|
||||
.desired_rows(rows)
|
||||
.layouter(&mut layouter)
|
||||
.show(ui);
|
||||
if output.response.changed() {
|
||||
|
||||
@@ -105,7 +105,12 @@ pub(super) fn build_rows(files: &[String], collapsed: &HashSet<String>) -> Vec<R
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The file list down the left-hand side.
|
||||
///
|
||||
/// Collapses through `show_animated`, which gives the width back to the
|
||||
/// editor rather than leaving an empty column.
|
||||
pub(super) fn left_pane(&mut self, ctx: &egui::Context) {
|
||||
let show_files = self.config.show_file_panel;
|
||||
egui::SidePanel::left("files")
|
||||
.resizable(true)
|
||||
.default_width(260.0)
|
||||
@@ -113,7 +118,7 @@ impl App {
|
||||
// that asks for more width than there is would otherwise push it
|
||||
// wider every frame.
|
||||
.width_range(160.0..=460.0)
|
||||
.show(ctx, |ui| {
|
||||
.show_animated(ctx, show_files, |ui| {
|
||||
// The default theme renders unselected list rows fairly dim; bump
|
||||
// the widget text colours so file names stay legible (especially in
|
||||
// dark mode) without affecting the rest of the app.
|
||||
|
||||
@@ -443,6 +443,11 @@ impl eframe::App for App {
|
||||
self.save_current();
|
||||
}
|
||||
|
||||
// Ctrl+D folds the panels away for distraction-free writing, and back.
|
||||
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::D)) {
|
||||
self.toggle_distraction_free();
|
||||
}
|
||||
|
||||
// Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace.
|
||||
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) {
|
||||
self.open_find(true);
|
||||
|
||||
+43
-4
@@ -280,7 +280,8 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Title and author written into exported documents.
|
||||
/// Title, author and contact details written into exported documents, plus
|
||||
/// whether an export opens with a title page carrying them.
|
||||
pub(super) fn manuscript_settings_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_manuscript_settings;
|
||||
let mut close = false;
|
||||
@@ -320,12 +321,50 @@ impl App {
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Contact:");
|
||||
let r = ui
|
||||
.add(
|
||||
egui::TextEdit::multiline(&mut self.config.manuscript_contact)
|
||||
.hint_text("Address, email, phone — a line each")
|
||||
.desired_width(240.0)
|
||||
.desired_rows(4),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Shown under your name on the title page, laid out \
|
||||
line for line as typed",
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
});
|
||||
ui.separator();
|
||||
let r = ui
|
||||
.checkbox(
|
||||
&mut self.config.manuscript_title_page,
|
||||
"Begin exports with a title page",
|
||||
)
|
||||
.on_hover_text(
|
||||
"A page of its own carrying the title, the author and the \
|
||||
contact details, with the first chapter starting after it",
|
||||
);
|
||||
save_now |= r.changed();
|
||||
let r = ui
|
||||
.checkbox(
|
||||
&mut self.config.manuscript_standard_format,
|
||||
"Standard manuscript format",
|
||||
)
|
||||
.on_hover_text(
|
||||
"What an agent or editor expects a submission in: 12pt \
|
||||
Courier, double-spaced, half-inch paragraph indents, a \
|
||||
Surname / Title / page header, chapters opening a third \
|
||||
of the way down, and # for a scene break",
|
||||
);
|
||||
save_now |= r.changed();
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Written into exported .odt files as their document \
|
||||
properties, which is what a word processor shows under \
|
||||
File ▸ Properties.",
|
||||
"The title and author are also written into exported .odt \
|
||||
files as their document properties, which is what a word \
|
||||
processor shows under File ▸ Properties.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
|
||||
+88
-1
@@ -6,8 +6,15 @@ 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) {
|
||||
egui::TopBottomPanel::top("top").show(ctx, |ui| {
|
||||
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:");
|
||||
@@ -56,6 +63,16 @@ impl App {
|
||||
if ui.button("Export ODT").clicked() {
|
||||
self.export_odt();
|
||||
}
|
||||
// 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")
|
||||
@@ -310,6 +327,35 @@ impl App {
|
||||
}
|
||||
});
|
||||
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()
|
||||
@@ -375,6 +421,30 @@ impl App {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -535,6 +605,23 @@ impl App {
|
||||
/// 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.
|
||||
|
||||
+103
-2
@@ -9,6 +9,55 @@ impl App {
|
||||
&self.config.workspace
|
||||
}
|
||||
|
||||
/// The project folder: the root holding the characters, the outline and the
|
||||
/// reference material alongside the draft folder.
|
||||
///
|
||||
/// In project mode the workspace already *is* that root. Otherwise the
|
||||
/// workspace is a folder of chapters one level down, and the enclosing git
|
||||
/// work tree is the project around it — including one still awaiting the
|
||||
/// user's confirmation, since which folder to show in a file manager is a
|
||||
/// smaller question than which repository to commit to.
|
||||
///
|
||||
/// Falls back to the workspace when there is no repository, rather than
|
||||
/// guessing at a parent: an unversioned folder of chapters has no project
|
||||
/// around it that we can point to with any confidence. Reads only cached
|
||||
/// state, because the toolbar asks for this every frame.
|
||||
pub(super) fn project_root(&self) -> &Path {
|
||||
project_root_of(
|
||||
&self.config.workspace,
|
||||
self.manuscript_dir.is_some(),
|
||||
self.repo_root.as_deref(),
|
||||
self.pending_repo.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Show the project folder in the desktop's file manager.
|
||||
pub(super) fn open_project_folder(&mut self) {
|
||||
let path = self.project_root().to_path_buf();
|
||||
if !path.is_dir() {
|
||||
self.status = format!("No such folder: {}", path.display());
|
||||
return;
|
||||
}
|
||||
// xdg-open picks whichever file manager the session provides, the same
|
||||
// desktop-portal assumption the native file dialogs already make.
|
||||
let spawned = std::process::Command::new("xdg-open")
|
||||
.arg(&path)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
match spawned {
|
||||
Ok(mut child) => {
|
||||
// xdg-open hands off to the file manager and exits immediately.
|
||||
// Reap it off-thread so each click does not leave a zombie behind.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
self.status = format!("Opened {}", path.display());
|
||||
}
|
||||
Err(e) => self.status = format!("Could not open {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// (Re)load the file list for the current workspace, creating the directory
|
||||
/// if needed, and refresh git status.
|
||||
pub(super) fn open_workspace(&mut self) {
|
||||
@@ -656,8 +705,9 @@ impl App {
|
||||
Some((goal, count_words(&header.body)))
|
||||
}
|
||||
|
||||
/// Document properties for an export: the configured title and author, with
|
||||
/// the title falling back to the project folder's own name.
|
||||
/// Document properties for an export: the configured title, author and
|
||||
/// contact details, with the title falling back to the project folder's own
|
||||
/// name.
|
||||
pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta {
|
||||
let title = match self.config.manuscript_title.trim() {
|
||||
"" => self
|
||||
@@ -671,6 +721,9 @@ impl App {
|
||||
odt::DocMeta {
|
||||
title,
|
||||
author: self.config.manuscript_author.trim().to_string(),
|
||||
contact: self.config.manuscript_contact.clone(),
|
||||
title_page: self.config.manuscript_title_page,
|
||||
standard_format: self.config.manuscript_standard_format,
|
||||
subject: String::new(),
|
||||
keywords: String::new(),
|
||||
word_count: chapters.iter().map(|c| count_words(&c.markdown)).sum(),
|
||||
@@ -956,6 +1009,22 @@ impl App {
|
||||
/// open whatever sorts first across the whole project — a scratch-pad note,
|
||||
/// typically — and in a file the user cannot see in the tree. Reference files
|
||||
/// are still the fallback, for a project whose manuscript folder is empty.
|
||||
/// Which folder to treat as the project root, from state the app already holds.
|
||||
///
|
||||
/// Split out from [`App::project_root`] so the choice can be exercised without
|
||||
/// standing up a whole `App`.
|
||||
fn project_root_of<'a>(
|
||||
workspace: &'a Path,
|
||||
in_project_mode: bool,
|
||||
repo_root: Option<&'a Path>,
|
||||
pending_repo: Option<&'a Path>,
|
||||
) -> &'a Path {
|
||||
if in_project_mode {
|
||||
return workspace;
|
||||
}
|
||||
repo_root.or(pending_repo).unwrap_or(workspace)
|
||||
}
|
||||
|
||||
pub(super) fn first_listed(files: &[String], manuscript_dir: Option<&str>) -> Option<usize> {
|
||||
let in_book = |name: &String| match manuscript_dir {
|
||||
Some(dir) => is_within(name, dir),
|
||||
@@ -1075,6 +1144,38 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The folder the "open the project folder" button points at, across the
|
||||
/// shapes a workspace can take.
|
||||
#[test]
|
||||
fn the_project_root_is_the_folder_the_manuscript_sits_in() {
|
||||
let ws = Path::new("/books/My Book/06-First Draft");
|
||||
let project = Path::new("/books/My Book");
|
||||
|
||||
// Project mode: the workspace already is the project root, so an
|
||||
// enclosing repository must not pull the answer above it.
|
||||
assert_eq!(
|
||||
project_root_of(project, true, Some(Path::new("/books")), None),
|
||||
project
|
||||
);
|
||||
|
||||
// The draft folder opened on its own, inside an adopted repository.
|
||||
assert_eq!(project_root_of(ws, false, Some(project), None), project);
|
||||
|
||||
// The same, while the repository is still awaiting confirmation:
|
||||
// showing a folder is a smaller question than committing to it.
|
||||
assert_eq!(project_root_of(ws, false, None, Some(project)), project);
|
||||
|
||||
// An adopted repository wins over a stale pending one.
|
||||
assert_eq!(
|
||||
project_root_of(ws, false, Some(project), Some(Path::new("/books"))),
|
||||
project
|
||||
);
|
||||
|
||||
// A plain, unversioned folder of chapters has no project around it, so
|
||||
// the workspace stands in rather than a guessed-at parent.
|
||||
assert_eq!(project_root_of(ws, false, None, None), ws);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untitled_name_fills_the_first_free_slot() {
|
||||
assert_eq!(next_untitled_name(|_| false), "untitled-1.md");
|
||||
|
||||
Reference in New Issue
Block a user