Split app.rs into an app/ module tree
src/app.rs had grown to 3,700 lines, ~2,400 of them a single impl App block. Move it to src/app/mod.rs and spread the behaviour across eleven child modules grouped by feature: workspace, grammar, spelling, beats, find, editor, autocomplete, file_list, ui, style and util. The new modules are children of app rather than siblings, so they still reach App's private fields without widening its interface; methods and free helpers that are now used across module boundaries are marked pub(super). mod.rs keeps the state types, App::new and the eframe::App update loop. This is pure code motion - every non-blank line of the original file reappears exactly once, and the only edits are the pub(super) markers, the module scaffolding, and rewrapping five signatures that the added prefix pushed past 100 columns. Largest file is now editor.rs at 520 lines. Tests still 56/56, and cargo clippy --release reports the same five warnings as before the split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
+493
@@ -0,0 +1,493 @@
|
||||
//! Top-level window chrome: the menu bar, the toolbar, the central editor
|
||||
//! panel, the markdown preview and the repo-adoption prompt.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
// ---- UI ----------------------------------------------------------------
|
||||
|
||||
pub(super) fn top_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("top").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Workspace:");
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.workspace_input)
|
||||
.desired_width(300.0),
|
||||
);
|
||||
if ui.button("📂").on_hover_text("Browse for workspace folder").clicked() {
|
||||
self.browse_workspace();
|
||||
}
|
||||
if ui.button("Open").clicked()
|
||||
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
{
|
||||
self.save_current();
|
||||
self.config.workspace = PathBuf::from(self.workspace_input.trim());
|
||||
self.config.save();
|
||||
self.open_workspace();
|
||||
}
|
||||
ui.separator();
|
||||
if self.is_repo {
|
||||
let hover = match &self.repo_root {
|
||||
Some(root) if root.as_path() != self.workspace() => {
|
||||
format!("Commit & push to the repository at {}", root.display())
|
||||
}
|
||||
_ => "Commit & push this workspace's repository".to_string(),
|
||||
};
|
||||
if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() {
|
||||
self.git_sync();
|
||||
}
|
||||
} else if ui.button("Init git").clicked() {
|
||||
self.git_init();
|
||||
}
|
||||
if ui.button("Log").clicked() {
|
||||
self.show_log = !self.show_log;
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Export:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.export_input).desired_width(300.0),
|
||||
);
|
||||
if ui.button("📂").on_hover_text("Browse for export .odt file").clicked() {
|
||||
self.browse_export();
|
||||
}
|
||||
if ui.button("Export ODT").clicked() {
|
||||
self.export_odt();
|
||||
}
|
||||
ui.separator();
|
||||
if ui
|
||||
.checkbox(&mut self.config.show_preview, "Preview")
|
||||
.changed()
|
||||
{
|
||||
self.config.save();
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Draft marker:")
|
||||
.on_hover_text(
|
||||
"Text above this line in each file (its header) is dropped on export; \
|
||||
# Title: / # Slug: lines become the chapter heading and caption. \
|
||||
Leave blank to export files whole.",
|
||||
);
|
||||
let marker_resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.draft_marker)
|
||||
.desired_width(140.0),
|
||||
);
|
||||
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(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Grammar:").on_hover_text(
|
||||
"Grammar & spelling via a LanguageTool server. \
|
||||
Configure it in Settings ▸ LanguageTool.",
|
||||
);
|
||||
let checking = self.lt_rx.is_some();
|
||||
if ui
|
||||
.add_enabled(
|
||||
!checking && self.selected.is_some(),
|
||||
egui::Button::new("✓ Check"),
|
||||
)
|
||||
.on_hover_text("Check the current file's grammar and spelling")
|
||||
.clicked()
|
||||
{
|
||||
self.start_lt_check(ctx);
|
||||
}
|
||||
if !self.lt_matches.is_empty()
|
||||
&& ui.button("Clear").on_hover_text("Clear check results").clicked()
|
||||
{
|
||||
self.clear_lt();
|
||||
}
|
||||
if ui
|
||||
.button("⚙")
|
||||
.on_hover_text("LanguageTool settings")
|
||||
.clicked()
|
||||
{
|
||||
self.show_settings = true;
|
||||
}
|
||||
if !self.lt_status.is_empty() {
|
||||
ui.label(egui::RichText::new(&self.lt_status).weak());
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Spelling:").on_hover_text(
|
||||
"Offline spell check (Hunspell dictionaries). Underlines misspellings \
|
||||
as you type when LanguageTool results aren't current; right-click a \
|
||||
word for suggestions.",
|
||||
);
|
||||
if ui
|
||||
.checkbox(&mut self.config.spell_check, "Check as I type")
|
||||
.on_hover_text("Underline misspellings live using the dictionary below")
|
||||
.changed()
|
||||
{
|
||||
self.config.save();
|
||||
if self.config.spell_check {
|
||||
// Re-check the current buffer straight away.
|
||||
self.spell_dirty = true;
|
||||
self.spell_last_edit = None;
|
||||
}
|
||||
}
|
||||
ui.label("Dictionary:");
|
||||
let current = self.config.spell_language.clone();
|
||||
let mut pick: Option<String> = None;
|
||||
egui::ComboBox::from_id_salt("spell_dict")
|
||||
.selected_text(current.clone())
|
||||
.show_ui(ui, |ui| {
|
||||
for d in &self.spell_dicts {
|
||||
if ui
|
||||
.selectable_label(current == d.id, &d.label)
|
||||
.clicked()
|
||||
{
|
||||
pick = Some(d.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(id) = pick {
|
||||
self.set_spell_language(&id);
|
||||
}
|
||||
if !self.spell_status.is_empty() {
|
||||
ui.label(egui::RichText::new(&self.spell_status).weak());
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
let dirty = if self.dirty { " • unsaved" } else { "" };
|
||||
ui.label(format!("{}{dirty}", self.status));
|
||||
|
||||
let goal = self.current_goal();
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(idx) = self.selected {
|
||||
let name = &self.files[idx];
|
||||
let total = count_words(&self.buffer);
|
||||
let baseline =
|
||||
self.session_start_counts.get(name).copied().unwrap_or(0);
|
||||
let delta = total as i64 - baseline as i64;
|
||||
let sign = if delta < 0 { "-" } else { "+" };
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"{} words · {sign}{} this session",
|
||||
thousands(total),
|
||||
thousands(delta.unsigned_abs() as usize),
|
||||
))
|
||||
.weak(),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Words in the current file · net change since this session opened",
|
||||
);
|
||||
|
||||
// Progress toward the file's Word Count Target, if set.
|
||||
if let Some((goal, prose)) = goal {
|
||||
let (frac, color, text) = goal_progress(goal, prose);
|
||||
ui.separator();
|
||||
ui.add(
|
||||
egui::ProgressBar::new(frac)
|
||||
.desired_width(150.0)
|
||||
.fill(color)
|
||||
.text(egui::RichText::new(text).small()),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Prose words vs the file's “Word Count Target” header \
|
||||
(counts the body below the draft marker). Amber = under \
|
||||
target, green = in range, blue = over.",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// The application menu bar (File / View / Settings).
|
||||
pub(super) fn menu_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("menubar").show(ctx, |ui| {
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("📂 Open workspace…").clicked() {
|
||||
ui.close_menu();
|
||||
self.browse_workspace();
|
||||
}
|
||||
if ui.button("Export ODT").clicked() {
|
||||
ui.close_menu();
|
||||
self.export_odt();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
ui.close_menu();
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
ui.menu_button("Edit", |ui| {
|
||||
if ui.button("🔍 Find / Replace…").clicked() {
|
||||
ui.close_menu();
|
||||
self.open_find(true);
|
||||
}
|
||||
});
|
||||
ui.menu_button("Tools", |ui| {
|
||||
let busy = self.beats_rx.is_some();
|
||||
if ui
|
||||
.add_enabled(
|
||||
!busy,
|
||||
egui::Button::new("✨ Generate plot beats (Mistral)…"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
ui.close_menu();
|
||||
self.generate_plot_beats(ctx);
|
||||
}
|
||||
});
|
||||
ui.menu_button("View", |ui| {
|
||||
if ui
|
||||
.checkbox(&mut self.config.show_preview, "Preview pane")
|
||||
.clicked()
|
||||
{
|
||||
self.config.save();
|
||||
}
|
||||
ui.checkbox(&mut self.show_lt_panel, "Grammar panel");
|
||||
ui.checkbox(&mut self.show_log, "Git log");
|
||||
});
|
||||
ui.menu_button("Settings", |ui| {
|
||||
if ui.button("LanguageTool…").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_settings = true;
|
||||
}
|
||||
if ui.button("Mistral…").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_mistral_settings = true;
|
||||
}
|
||||
});
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("📝 Markdown cheatsheet").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_cheatsheet = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn central(&mut self, ctx: &egui::Context) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
match self.selected {
|
||||
Some(idx) => {
|
||||
let name = self.files[idx].clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&name);
|
||||
if ui.button("💾 Save").clicked() {
|
||||
self.dirty = true; // ensure save runs
|
||||
self.save_current();
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Zoom:");
|
||||
if ui
|
||||
.add(
|
||||
egui::Slider::new(&mut self.config.editor_zoom, -50.0..=200.0)
|
||||
.suffix("%")
|
||||
.step_by(5.0),
|
||||
)
|
||||
.on_hover_text("Editor text size (0% = default)")
|
||||
.changed()
|
||||
{
|
||||
self.config.save();
|
||||
}
|
||||
if ui
|
||||
.button("Reset")
|
||||
.on_hover_text("Reset zoom to 0%")
|
||||
.clicked()
|
||||
{
|
||||
self.config.editor_zoom = 0.0;
|
||||
self.config.save();
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Contrast:");
|
||||
if ui
|
||||
.add(
|
||||
egui::Slider::new(
|
||||
&mut self.config.editor_text_contrast,
|
||||
0.0..=100.0,
|
||||
)
|
||||
.suffix("%")
|
||||
.step_by(5.0),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Editor text contrast (0% = theme default; higher \
|
||||
brightens the text in dark mode)",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
self.config.save();
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Chapter title:");
|
||||
let auto = self.auto_title(idx, &self.buffer);
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.title_input)
|
||||
.hint_text(format!("auto: {auto}"))
|
||||
.desired_width(320.0),
|
||||
);
|
||||
if resp.changed() {
|
||||
self.set_title_for_current();
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new("used as the ODT chapter heading")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
if self.show_find {
|
||||
self.find_bar(ui);
|
||||
}
|
||||
|
||||
if self.config.show_preview {
|
||||
// Editor + read-only source-ish preview side by side.
|
||||
let full = ui.available_size();
|
||||
ui.horizontal_top(|ui| {
|
||||
let col_w = full.x / 2.0 - 6.0;
|
||||
ui.allocate_ui(egui::vec2(col_w, full.y), |ui| {
|
||||
self.editor(ui);
|
||||
});
|
||||
ui.separator();
|
||||
ui.allocate_ui(egui::vec2(col_w, full.y), |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("preview")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
render_preview(ui, &self.buffer);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self.editor(ui);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label("Select a file on the left, or create a new one.");
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask the user whether to adopt a git repository found in a parent
|
||||
/// directory of the freshly opened workspace.
|
||||
pub(super) fn repo_prompt(&mut self, ctx: &egui::Context) {
|
||||
let Some(root) = self.pending_repo.clone() else {
|
||||
return;
|
||||
};
|
||||
let mut adopt = false;
|
||||
let mut decline = false;
|
||||
egui::Window::new("Use enclosing git repository?")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.label(
|
||||
"This folder isn't a git repository, but one was found in a \
|
||||
parent directory:",
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(root.display().to_string()).strong());
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
"Use it for version control (Sync commits and pushes to this \
|
||||
repo)? Otherwise the workspace is left without git; you can \
|
||||
still Init a separate repository here.",
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Use this repository").clicked() {
|
||||
adopt = true;
|
||||
}
|
||||
if ui.button("No, keep separate").clicked() {
|
||||
decline = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if adopt {
|
||||
self.adopt_pending_repo();
|
||||
} else if decline {
|
||||
self.decline_pending_repo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Very small markdown-ish preview (headings emphasised, everything else plain).
|
||||
pub(super) fn render_preview(ui: &mut egui::Ui, markdown: &str) {
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
let parser = Parser::new_ext(markdown, Options::ENABLE_STRIKETHROUGH);
|
||||
let mut heading: Option<HeadingLevel> = None;
|
||||
let mut line = String::new();
|
||||
let mut bold = false;
|
||||
let mut italic = false;
|
||||
|
||||
let flush = |ui: &mut egui::Ui, line: &mut String, heading: &mut Option<HeadingLevel>| {
|
||||
if line.trim().is_empty() {
|
||||
line.clear();
|
||||
*heading = None;
|
||||
return;
|
||||
}
|
||||
let text = line.clone();
|
||||
match heading {
|
||||
Some(HeadingLevel::H1) => {
|
||||
ui.label(egui::RichText::new(text).size(22.0).strong());
|
||||
}
|
||||
Some(HeadingLevel::H2) => {
|
||||
ui.label(egui::RichText::new(text).size(18.0).strong());
|
||||
}
|
||||
Some(_) => {
|
||||
ui.label(egui::RichText::new(text).size(15.0).strong());
|
||||
}
|
||||
None => {
|
||||
ui.label(text);
|
||||
}
|
||||
}
|
||||
line.clear();
|
||||
*heading = None;
|
||||
};
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => heading = Some(level),
|
||||
Event::End(TagEnd::Heading(_)) => flush(ui, &mut line, &mut heading),
|
||||
Event::End(TagEnd::Paragraph) => flush(ui, &mut line, &mut heading),
|
||||
Event::Start(Tag::Item) => line.push_str("• "),
|
||||
Event::End(TagEnd::Item) => flush(ui, &mut line, &mut heading),
|
||||
Event::Start(Tag::Strong) => bold = true,
|
||||
Event::End(TagEnd::Strong) => bold = false,
|
||||
Event::Start(Tag::Emphasis) => italic = true,
|
||||
Event::End(TagEnd::Emphasis) => italic = false,
|
||||
Event::Text(t) | Event::Code(t) => {
|
||||
let _ = (bold, italic);
|
||||
line.push_str(&t);
|
||||
}
|
||||
Event::SoftBreak | Event::HardBreak => line.push(' '),
|
||||
Event::Rule => {
|
||||
ui.separator();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(ui, &mut line, &mut heading);
|
||||
}
|
||||
Reference in New Issue
Block a user