Add project-mode tooling: characters, outline, diff and revision status
Rounds out project mode, where the workspace is the project root and one subfolder holds the manuscript proper: - Characters and Outline windows, backed by new `characters` and `outline` modules that read the cast from character sheets and measure how much of the snowflake outline is actually written. - Edit ▸ Changes… diffs the open file against its last committed version. - Revision status, per-file and project word counts, an archive action and hidden folders in the file panel. - Chapter-file export alongside the ODT master, richer header parsing, and a project word list for names and invented terms. - The export path now follows the workspace: opening a project points it at that project root, keeping a file name you chose yourself and re-deriving one that merely echoed the folder it sat in. - Clicking an issue in the grammar/spelling panel takes the editor to it, selecting the words and centring them; applying a suggestion jumps to the rewritten text as well. README covers the new windows and workflows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bSn3Xijp8GofZVUnRX4oq
This commit is contained in:
+84
-1
@@ -110,7 +110,7 @@ impl App {
|
||||
self.buffer = new_text;
|
||||
self.dirty = true;
|
||||
// Restore the caret/selection around the change.
|
||||
let mut state = output.state;
|
||||
let mut state = output.state.clone();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
|
||||
egui::text::CCursor::new(new_sel.start),
|
||||
egui::text::CCursor::new(new_sel.end),
|
||||
@@ -120,6 +120,32 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// Take the editor to an issue picked in the results panel:
|
||||
// select the words, centre them, and hand focus back so the
|
||||
// user can type the correction straight away.
|
||||
if let Some((start, end)) = self.issue_jump.take() {
|
||||
let (cs, ce) = (
|
||||
byte_to_char(&self.buffer, start),
|
||||
byte_to_char(&self.buffer, end),
|
||||
);
|
||||
let rect = output
|
||||
.galley
|
||||
.pos_from_ccursor(egui::text::CCursor::new(cs))
|
||||
.union(output.galley.pos_from_ccursor(egui::text::CCursor::new(ce)))
|
||||
.translate(output.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(rect, Some(egui::Align::Center));
|
||||
let mut state = output.state.clone();
|
||||
state
|
||||
.cursor
|
||||
.set_char_range(Some(egui::text::CCursorRange::two(
|
||||
egui::text::CCursor::new(cs),
|
||||
egui::text::CCursor::new(ce),
|
||||
)));
|
||||
state.store(ui.ctx(), output.response.id);
|
||||
ui.ctx().memory_mut(|m| m.request_focus(output.response.id));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
// Scroll the active search match into view when requested (on
|
||||
// open, Next/Prev, Enter, or after a replace).
|
||||
if self.find_scroll {
|
||||
@@ -162,6 +188,15 @@ impl App {
|
||||
Some((i, reps))
|
||||
});
|
||||
let mut chosen: Option<(usize, usize)> = None;
|
||||
// The word under the menu, so it can be added to the word list.
|
||||
let target_word: Option<String> = self.spell_menu.and_then(|i| {
|
||||
let m = self.current_matches().get(i)?;
|
||||
m.spelling
|
||||
.then(|| self.buffer.get(m.start..m.end))
|
||||
.flatten()
|
||||
.map(str::to_string)
|
||||
});
|
||||
let mut accept: Option<String> = None;
|
||||
output.response.context_menu(|ui| {
|
||||
match &menu {
|
||||
Some((i, reps)) if !reps.is_empty() => {
|
||||
@@ -180,7 +215,25 @@ impl App {
|
||||
ui.label(egui::RichText::new("No spelling issue here").weak());
|
||||
}
|
||||
}
|
||||
// A character or place name is not a misspelling; let it be
|
||||
// accepted for good rather than corrected every time.
|
||||
if let Some(word) = &target_word {
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(format!("📗 Add “{word}” to the word list"))
|
||||
.on_hover_text(
|
||||
"Accept this word from now on, for this workspace",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
accept = Some(word.clone());
|
||||
ui.close_menu();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(word) = accept {
|
||||
self.add_to_dictionary(&word);
|
||||
}
|
||||
if let Some((i, j)) = chosen {
|
||||
self.apply_current_fix(i, j);
|
||||
self.spell_menu = None;
|
||||
@@ -401,6 +454,14 @@ pub(super) fn char_to_byte(s: &str, char_idx: usize) -> usize {
|
||||
.unwrap_or(s.len())
|
||||
}
|
||||
|
||||
/// Character index of byte offset `byte` in `s`, clamped to the end. An offset
|
||||
/// landing inside a character — a stale match against an edited buffer — is
|
||||
/// clamped to the next character boundary, matching how [`crate::langtool`]
|
||||
/// resolves its UTF-16 offsets, so a jump never lands mid-character.
|
||||
pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
|
||||
s.char_indices().take_while(|(b, _)| *b < byte).count()
|
||||
}
|
||||
|
||||
/// Apply an inline-format action to `text` given a selection in *character*
|
||||
/// indices, returning the new text and the new selection (also character indices).
|
||||
///
|
||||
@@ -470,6 +531,28 @@ pub(super) fn apply_format(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn byte_to_char_is_the_inverse_of_char_to_byte() {
|
||||
// "café!" — é is 2 bytes, so byte and char indices diverge after it.
|
||||
let text = "café!";
|
||||
for (i, _) in text.char_indices() {
|
||||
assert_eq!(char_to_byte(text, byte_to_char(text, i)), i);
|
||||
}
|
||||
assert_eq!(byte_to_char(text, 0), 0);
|
||||
assert_eq!(byte_to_char(text, 3), 3); // é
|
||||
assert_eq!(byte_to_char(text, 5), 4); // '!' sits after the 2-byte é
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_to_char_clamps_past_the_end_and_inside_a_character() {
|
||||
let text = "a😀b";
|
||||
assert_eq!(byte_to_char(text, text.len()), 3);
|
||||
assert_eq!(byte_to_char(text, 99), 3);
|
||||
// Byte 3 is inside the 4-byte emoji, so it clamps forward to the
|
||||
// boundary after it rather than splitting the character.
|
||||
assert_eq!(byte_to_char(text, 3), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_a_selection_in_bold_and_keeps_it_selected() {
|
||||
let (text, sel) = apply_format("a word here", 2..6, Fmt::Bold);
|
||||
|
||||
Reference in New Issue
Block a user