Add zero-pad option for numbered chapter titles

A "Zero-pad #" checkbox pads chapter titles that default to their
position number with leading zeros to a uniform width (the digit count
of the largest chapter number, e.g. 03. of 12). Padding never applies to
"# Title:" headers or manual overrides. The setting persists in config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
landon
2026-07-26 14:59:13 -05:00
parent 6680c001bf
commit f05ee655c3
3 changed files with 57 additions and 11 deletions
+3 -1
View File
@@ -69,7 +69,9 @@ Terminal=false
auto-derived one (the field shows the automatic title as a hint). Leaving it
blank falls back to a `# Title:` line in the header, and if there is none, to
the chapter's **position in the left pane** followed by a period (`1.`, `2.`,
…). Titles are saved to `titles.json` and sync with git.
…). Tick **Zero-pad #** in the top bar to pad those numbered titles with
leading zeros to a uniform width (e.g. `03.` when there are 12 chapters).
Titles are saved to `titles.json` and sync with git.
5. Set the **Export** path (type it, or click **📂** to choose the `.odt` file in a
native save dialog) and press **Export ODT** to produce the concatenated
document. Each file becomes a chapter headed by its title, optionally followed
+49 -10
View File
@@ -294,17 +294,28 @@ impl App {
};
}
/// Digits to zero-pad a defaulted chapter number to: the width of the largest
/// chapter number when padding is enabled, otherwise 1 (no padding).
fn index_pad_width(&self) -> usize {
if self.config.zero_pad_index {
self.files.len().to_string().len().max(1)
} else {
1
}
}
/// The title a chapter at `idx` would get without a manual override: its
/// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint
/// in the chapter-title field.
fn auto_title(&self, idx: usize, markdown: &str) -> String {
let header = crate::preprocess::parse(markdown, &self.config.draft_marker);
resolve_chapter_title(None, header.title.as_deref(), idx)
resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width())
}
fn export_odt(&mut self) {
self.save_current();
let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
let mut chapters = Vec::new();
for (i, name) in self.files.iter().enumerate() {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
@@ -318,6 +329,7 @@ impl App {
self.titles.get(name).map(String::as_str),
header.title.as_deref(),
i,
pad_width,
);
chapters.push(Chapter {
title,
@@ -471,6 +483,17 @@ impl App {
if marker_resp.lost_focus() {
self.config.save();
}
ui.separator();
if ui
.checkbox(&mut self.config.zero_pad_index, "Zero-pad #")
.on_hover_text(
"When a chapter title defaults to its number, pad it with \
leading zeros (e.g. 03. of 12).",
)
.changed()
{
self.config.save();
}
});
ui.add_space(4.0);
});
@@ -815,18 +838,20 @@ fn render_preview(ui: &mut egui::Ui, markdown: &str) {
}
/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
/// `# Title:` header value, otherwise the chapter's 1-based position (e.g. "3.").
/// `# Title:` header value, otherwise the chapter's 1-based position followed by
/// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding).
fn resolve_chapter_title(
override_title: Option<&str>,
header_title: Option<&str>,
index: usize,
pad_width: usize,
) -> String {
override_title
.map(str::trim)
.filter(|t| !t.is_empty())
.or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty()))
.map(|t| t.to_string())
.unwrap_or_else(|| format!("{}.", index + 1))
.unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width))
}
/// Count words in a string: runs of non-whitespace separated by whitespace.
@@ -873,19 +898,33 @@ mod tests {
#[test]
fn chapter_title_falls_back_to_position() {
// No override, no header title -> 1-based index with a period.
assert_eq!(resolve_chapter_title(None, None, 0), "1.");
assert_eq!(resolve_chapter_title(None, None, 2), "3.");
// No override, no header title -> 1-based index with a period (width 1).
assert_eq!(resolve_chapter_title(None, None, 0, 1), "1.");
assert_eq!(resolve_chapter_title(None, None, 2, 1), "3.");
// Header title is used when present.
assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4), "The Gate");
assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4, 1), "The Gate");
// Override wins over everything, even a header title.
assert_eq!(
resolve_chapter_title(Some("My Override"), Some("The Gate"), 4),
resolve_chapter_title(Some("My Override"), Some("The Gate"), 4, 1),
"My Override"
);
// Blank/whitespace override or header title are ignored.
assert_eq!(resolve_chapter_title(Some(" "), None, 1), "2.");
assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6), "7.");
assert_eq!(resolve_chapter_title(Some(" "), None, 1, 1), "2.");
assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6, 1), "7.");
}
#[test]
fn chapter_number_zero_pads_to_width() {
// Width 2 pads single digits; wider numbers are unaffected.
assert_eq!(resolve_chapter_title(None, None, 0, 2), "01.");
assert_eq!(resolve_chapter_title(None, None, 8, 2), "09.");
assert_eq!(resolve_chapter_title(None, None, 11, 2), "12.");
assert_eq!(resolve_chapter_title(None, None, 4, 3), "005.");
// Padding never applies to a real title.
assert_eq!(
resolve_chapter_title(None, Some("The Gate"), 0, 3),
"The Gate"
);
}
#[test]
+5
View File
@@ -16,6 +16,10 @@ pub struct Config {
/// An empty value disables header splitting.
#[serde(default = "default_marker")]
pub draft_marker: String,
/// When a chapter title defaults to its position number, pad it with leading
/// zeros to the width of the largest chapter number (e.g. "03." of 12).
#[serde(default)]
pub zero_pad_index: bool,
}
/// Default header/body separator, matching the manuscript drafting convention.
@@ -32,6 +36,7 @@ impl Default for Config {
export_path,
show_preview: false,
draft_marker: default_marker(),
zero_pad_index: false,
}
}
}