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 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 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.`, 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 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 native save dialog) and press **Export ODT** to produce the concatenated
document. Each file becomes a chapter headed by its title, optionally followed 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 /// 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 /// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint
/// in the chapter-title field. /// in the chapter-title field.
fn auto_title(&self, idx: usize, markdown: &str) -> String { fn auto_title(&self, idx: usize, markdown: &str) -> String {
let header = crate::preprocess::parse(markdown, &self.config.draft_marker); 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) { fn export_odt(&mut self) {
self.save_current(); self.save_current();
let marker = self.config.draft_marker.clone(); let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
let mut chapters = Vec::new(); let mut chapters = Vec::new();
for (i, name) in self.files.iter().enumerate() { for (i, name) in self.files.iter().enumerate() {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); 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), self.titles.get(name).map(String::as_str),
header.title.as_deref(), header.title.as_deref(),
i, i,
pad_width,
); );
chapters.push(Chapter { chapters.push(Chapter {
title, title,
@@ -471,6 +483,17 @@ impl App {
if marker_resp.lost_focus() { if marker_resp.lost_focus() {
self.config.save(); 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); 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 /// 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( fn resolve_chapter_title(
override_title: Option<&str>, override_title: Option<&str>,
header_title: Option<&str>, header_title: Option<&str>,
index: usize, index: usize,
pad_width: usize,
) -> String { ) -> String {
override_title override_title
.map(str::trim) .map(str::trim)
.filter(|t| !t.is_empty()) .filter(|t| !t.is_empty())
.or_else(|| header_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()) .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. /// Count words in a string: runs of non-whitespace separated by whitespace.
@@ -873,19 +898,33 @@ mod tests {
#[test] #[test]
fn chapter_title_falls_back_to_position() { fn chapter_title_falls_back_to_position() {
// No override, no header title -> 1-based index with a period. // No override, no header title -> 1-based index with a period (width 1).
assert_eq!(resolve_chapter_title(None, None, 0), "1."); assert_eq!(resolve_chapter_title(None, None, 0, 1), "1.");
assert_eq!(resolve_chapter_title(None, None, 2), "3."); assert_eq!(resolve_chapter_title(None, None, 2, 1), "3.");
// Header title is used when present. // 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. // Override wins over everything, even a header title.
assert_eq!( 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" "My Override"
); );
// Blank/whitespace override or header title are ignored. // Blank/whitespace override or header title are ignored.
assert_eq!(resolve_chapter_title(Some(" "), None, 1), "2."); assert_eq!(resolve_chapter_title(Some(" "), None, 1, 1), "2.");
assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6), "7."); 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] #[test]
+5
View File
@@ -16,6 +16,10 @@ pub struct Config {
/// An empty value disables header splitting. /// An empty value disables header splitting.
#[serde(default = "default_marker")] #[serde(default = "default_marker")]
pub draft_marker: String, 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. /// Default header/body separator, matching the manuscript drafting convention.
@@ -32,6 +36,7 @@ impl Default for Config {
export_path, export_path,
show_preview: false, show_preview: false,
draft_marker: default_marker(), draft_marker: default_marker(),
zero_pad_index: false,
} }
} }
} }