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:
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user