f35397b49b
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
560 lines
23 KiB
Rust
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;
|
|
}
|
|
}
|