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:
2026-08-25 17:58:59 -05:00
parent 66d3f9817e
commit f35397b49b
9 changed files with 1038 additions and 58 deletions
+103 -2
View File
@@ -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");