Files
md-manuscript/src/app/project.rs
T
landon f35397b49b 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
2026-08-25 17:58:59 -05:00

560 lines
23 KiB
Rust

//! **File ▸ New project…**: scaffolding a manuscript project from a
//! cookiecutter template, then opening its drafting subfolder as the workspace.
//!
//! Generation runs on a worker thread — the template's hooks can reach the
//! network — and the UI polls for the result, so the editor stays responsive.
use super::*;
/// Result of a background generation: the project directory, or a message.
pub(super) type ProjectMsg = Result<PathBuf, String>;
impl App {
/// Open the new-project dialog, prefilled from the last one.
pub(super) fn open_new_project(&mut self) {
self.np_name.clear();
self.np_description.clear();
self.np_author = self.config.project_author.clone();
self.np_parent = self.config.projects_dir.display().to_string();
self.np_status.clear();
self.show_new_project = true;
}
/// The environment a template's hooks are given. Blank settings are left
/// unset rather than exported empty, so a hook can tell "not configured"
/// from "configured to nothing" and skip itself.
fn hook_env(&self) -> Vec<(String, String)> {
[
("GITEA_URL", self.config.gitea_url.trim()),
("GITEA_USER", self.config.gitea_user.trim()),
("GITEA_TOKEN", self.config.gitea_token.trim()),
]
.into_iter()
.filter(|(_, value)| !value.is_empty())
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
/// Validate the dialog and kick off generation on a worker thread.
fn start_new_project(&mut self, ctx: &egui::Context) {
if self.np_rx.is_some() {
return; // one already running
}
let name = self.np_name.trim().to_string();
if name.is_empty() {
self.np_status = "Give the project a name.".to_string();
return;
}
// The name becomes a directory name, so the separators that would make
// it a path have to go.
if name.contains('/') || name.contains('\\') {
self.np_status = "The project name cannot contain / or \\.".to_string();
return;
}
let parent = PathBuf::from(self.np_parent.trim());
if self.np_parent.trim().is_empty() {
self.np_status = "Choose a folder to create the project in.".to_string();
return;
}
if parent.join(&name).exists() {
self.np_status = format!("{} already exists.", parent.join(&name).display());
return;
}
let template = self.config.project_template.clone();
let Some(bin) = crate::cookiecutter::resolve_binary(
&self.config.cookiecutter_bin,
dirs::home_dir().as_deref(),
) else {
self.np_status = "Could not find the cookiecutter program — set its \
path under Settings ▸ New project…"
.to_string();
return;
};
// Remember the choices that are worth prefilling next time.
self.config.project_author = self.np_author.trim().to_string();
self.config.projects_dir = parent.clone();
self.config.save();
let request = crate::cookiecutter::Request {
bin,
template,
output_dir: parent,
vars: vec![
crate::cookiecutter::Var::new("project_name", &name),
crate::cookiecutter::Var::new("author", self.np_author.trim()),
crate::cookiecutter::Var::new("description", self.np_description.trim()),
],
env: self.hook_env(),
run_hooks: self.config.project_run_hooks,
};
let (tx, rx) = std::sync::mpsc::channel();
self.np_rx = Some(rx);
self.np_status = format!("Creating {name}");
let ctx = ctx.clone();
std::thread::spawn(move || {
let _ = tx.send(crate::cookiecutter::generate(&request));
ctx.request_repaint();
});
}
/// Pick up a finished generation and open the new project.
pub(super) fn poll_new_project(&mut self) {
let received = self.np_rx.as_ref().and_then(|rx| rx.try_recv().ok());
let Some(result) = received else { return };
self.np_rx = None;
match result {
Ok(project) => {
self.show_new_project = false;
self.np_status.clear();
self.open_project(&project);
}
Err(e) => self.np_status = format!("{e}"),
}
}
/// Point the workspace at a generated project. The project *root* is opened
/// rather than the drafting folder: the app recognises the layout and treats
/// the drafting folder as the manuscript, keeping the characters, outline and
/// the rest reachable in the same tree.
fn open_project(&mut self, project: &Path) {
let subdir = self.config.project_open_subdir.trim();
let note = if subdir.is_empty() || project.join(subdir).is_dir() {
String::new()
} else {
format!(" (no {subdir} folder in it, so nothing is marked as the manuscript)")
};
self.save_current();
self.workspace_input = project.display().to_string();
self.config.workspace = project.to_path_buf();
// Export alongside the new project rather than into the previous one.
self.config.export_path = project.join(format!(
"{}.odt",
project
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("manuscript")
));
self.export_input = self.config.export_path.display().to_string();
self.config.save();
self.open_workspace();
let name = project
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project");
self.status = format!("Created {name}{note}{}", self.status);
}
/// The new-project dialog.
pub(super) fn new_project_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_new_project;
let mut close = false;
let mut create = false;
let mut browse = false;
let running = self.np_rx.is_some();
egui::Window::new("New project")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(430.0)
.show(ctx, |ui| {
ui.add_enabled_ui(!running, |ui| {
egui::Grid::new("new_project_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Name:");
ui.add(
egui::TextEdit::singleline(&mut self.np_name)
.hint_text("The Winter Gate")
.desired_width(280.0),
);
ui.end_row();
ui.label("Author:");
ui.add(
egui::TextEdit::singleline(&mut self.np_author)
.desired_width(280.0),
);
ui.end_row();
ui.label("Description:");
ui.add(
egui::TextEdit::singleline(&mut self.np_description)
.hint_text("A short description of the project.")
.desired_width(280.0),
);
ui.end_row();
ui.label("Create in:");
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.np_parent)
.desired_width(240.0),
);
if ui.button("📂").on_hover_text("Choose folder").clicked() {
browse = true;
}
});
ui.end_row();
});
ui.add_space(4.0);
let target = PathBuf::from(self.np_parent.trim())
.join(self.np_name.trim())
.display()
.to_string();
ui.label(
egui::RichText::new(format!("Creates: {target}"))
.small()
.weak()
.monospace(),
);
let subdir = self.config.project_open_subdir.trim();
if !subdir.is_empty() {
ui.label(
egui::RichText::new(format!(
"Then opens the project, with {subdir} as the manuscript."
))
.small()
.weak(),
);
}
if self.config.project_run_hooks {
ui.label(
egui::RichText::new(
"The template's hooks will run. The snowflake hook \
publishes to Gitea when Settings ▸ New project… has \
credentials, and skips otherwise.",
)
.small()
.weak(),
);
}
});
if running {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.spinner();
ui.label("Working…");
});
}
if !self.np_status.is_empty() {
ui.add_space(4.0);
ui.label(egui::RichText::new(&self.np_status).weak());
}
ui.separator();
ui.horizontal(|ui| {
if ui
.add_enabled(!running, egui::Button::new("Create"))
.clicked()
{
create = true;
}
if ui
.add_enabled(!running, egui::Button::new("Cancel"))
.clicked()
{
close = true;
}
});
});
self.show_new_project = open && !close;
if browse {
let mut dialog = rfd::FileDialog::new().set_title("Create the project in…");
let start = PathBuf::from(self.np_parent.trim());
if start.is_dir() {
dialog = dialog.set_directory(&start);
}
if let Some(path) = dialog.pick_folder() {
self.np_parent = path.display().to_string();
}
}
if create {
self.start_new_project(ctx);
}
}
/// 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;
egui::Window::new("Manuscript details")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(380.0)
.show(ctx, |ui| {
let mut save_now = false;
egui::Grid::new("manuscript_details_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Title:");
// Resolved first: the field borrows `self.config` mutably.
let fallback = self
.workspace()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Manuscript")
.to_string();
let r = ui
.add(
egui::TextEdit::singleline(&mut self.config.manuscript_title)
.hint_text(fallback)
.desired_width(240.0),
)
.on_hover_text("Blank uses the project folder's name");
save_now |= r.lost_focus();
ui.end_row();
ui.label("Author:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.manuscript_author)
.desired_width(240.0),
);
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(
"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(),
);
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
if save_now {
self.config.save();
}
});
let now_open = open && !close;
if self.show_manuscript_settings && !now_open {
self.config.save();
}
self.show_manuscript_settings = now_open;
}
/// Settings for **File ▸ New project…**: which template to render, how to
/// run it, and the credentials its hooks read.
pub(super) fn project_settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_project_settings;
let mut close = false;
let mut browse_template = false;
egui::Window::new("New-project settings")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(470.0)
.show(ctx, |ui| {
let mut save_now = false;
ui.label(egui::RichText::new("Template").strong());
ui.horizontal(|ui| {
let mut template = self.config.project_template.display().to_string();
let r = ui.add(
egui::TextEdit::singleline(&mut template)
.desired_width(330.0),
);
if r.changed() {
self.config.project_template = PathBuf::from(template.trim());
}
save_now |= r.lost_focus();
if ui.button("📂").on_hover_text("Choose folder").clicked() {
browse_template = true;
}
});
let template_ok = self
.config
.project_template
.join("cookiecutter.json")
.is_file();
ui.label(
egui::RichText::new(if template_ok {
"✔ cookiecutter.json found".to_string()
} else {
"✖ no cookiecutter.json in that folder".to_string()
})
.small()
.weak(),
);
ui.add_space(6.0);
egui::Grid::new("project_settings_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Manuscript folder:");
let r = ui
.add(
egui::TextEdit::singleline(
&mut self.config.project_open_subdir,
)
.hint_text("06-First Draft")
.desired_width(280.0),
)
.on_hover_text(
"Which folder of a project holds the book. Its files are ordered, numbered and exported; the rest of the project is reference. A leading number is optional, so “First Draft” also matches “06-First Draft”.",
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("cookiecutter path:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.cookiecutter_bin)
.hint_text("blank = search PATH and conda prefixes")
.desired_width(280.0),
);
save_now |= r.lost_focus();
ui.end_row();
});
let found = crate::cookiecutter::resolve_binary(
&self.config.cookiecutter_bin,
dirs::home_dir().as_deref(),
);
ui.label(
egui::RichText::new(match &found {
Some(path) => format!("{}", path.display()),
None => "✖ cookiecutter not found".to_string(),
})
.small()
.weak(),
);
ui.add_space(6.0);
if ui
.checkbox(
&mut self.config.project_run_hooks,
"Run the template's hooks",
)
.on_hover_text(
"Templates can run scripts after generating. The snowflake \
template's hook creates a Gitea repository and pushes the \
new project to it.",
)
.changed()
{
save_now = true;
}
ui.add_space(6.0);
ui.label(egui::RichText::new("Gitea (used by the template's hook)").strong());
egui::Grid::new("gitea_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Server URL:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.gitea_url)
.hint_text("https://gitea.example.com")
.desired_width(280.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("User:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.gitea_user)
.desired_width(280.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Token:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.gitea_token)
.password(true)
.desired_width(280.0),
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.label(
egui::RichText::new(
"Passed to the hook as GITEA_URL / GITEA_USER / GITEA_TOKEN. \
Leave blank and the hook skips publishing — the project is \
still created. The token is stored in this app's config file \
in plain text.",
)
.small()
.weak(),
);
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
if save_now {
self.config.save();
}
});
if browse_template {
let mut dialog = rfd::FileDialog::new().set_title("Choose a cookiecutter template");
if self.config.project_template.is_dir() {
dialog = dialog.set_directory(&self.config.project_template);
}
if let Some(path) = dialog.pick_folder() {
self.config.project_template = path;
self.config.save();
}
}
let now_open = open && !close;
if self.show_project_settings && !now_open {
self.config.save();
}
self.show_project_settings = now_open;
}
}