043cc692ac
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
604 lines
26 KiB
Rust
604 lines
26 KiB
Rust
//! The manuscript text editor: the widget itself, syntax/issue highlighting
|
|
//! for its layouter, and the markdown formatting hotkeys.
|
|
|
|
use super::*;
|
|
|
|
impl App {
|
|
pub(super) fn editor(&mut self, ui: &mut egui::Ui) {
|
|
// Base monospace size scaled by the zoom percentage. `desired_width` stays
|
|
// infinite and the scroll area is vertical-only, so text wraps to the pane
|
|
// width at any zoom (no horizontal scrolling).
|
|
let base = ui
|
|
.style()
|
|
.text_styles
|
|
.get(&egui::TextStyle::Monospace)
|
|
.map(|f| f.size)
|
|
.unwrap_or(12.0);
|
|
let size = (base * (1.0 + self.config.editor_zoom / 100.0)).max(4.0);
|
|
|
|
// Editor text colour, brightened toward max contrast by the contrast slider.
|
|
let text_color = editor_text_color(ui.visuals(), self.config.editor_text_contrast);
|
|
|
|
// Underline whichever check currently owns the buffer's issues
|
|
// (LanguageTool when fresh, otherwise the live spell checker).
|
|
let ranges: Vec<(usize, usize, bool)> = self
|
|
.current_matches()
|
|
.iter()
|
|
.map(|m| (m.start, m.end, m.spelling))
|
|
.collect();
|
|
|
|
// Search-match highlights (all matches shaded, the active one stronger).
|
|
let find_ranges: Vec<(usize, usize)> = if self.show_find {
|
|
self.find_matches.clone()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let find_active = if self.show_find {
|
|
Some(self.find_active)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
egui::ScrollArea::vertical()
|
|
.id_salt("editor")
|
|
.auto_shrink([false, false])
|
|
.show(ui, |ui| {
|
|
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
|
|
let job = build_editor_job(
|
|
text,
|
|
size,
|
|
text_color,
|
|
&ranges,
|
|
&find_ranges,
|
|
find_active,
|
|
wrap_width,
|
|
);
|
|
ui.fonts(|f| f.layout_job(job))
|
|
};
|
|
// Autocomplete: intercept navigation keys before the editor
|
|
// consumes them, acting on last frame's popup state.
|
|
let mut accept_field: Option<String> = None;
|
|
let mut dismiss = false;
|
|
if let Some(ac) = self.autocomplete.as_mut() {
|
|
ui.input_mut(|i| {
|
|
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) {
|
|
ac.selected = (ac.selected + 1) % ac.matches.len();
|
|
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) {
|
|
ac.selected = (ac.selected + ac.matches.len() - 1) % ac.matches.len();
|
|
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)
|
|
|| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)
|
|
{
|
|
accept_field = ac.matches.get(ac.selected).cloned();
|
|
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) {
|
|
dismiss = true;
|
|
}
|
|
});
|
|
}
|
|
if dismiss {
|
|
// Remember the dismissed partial so it doesn't immediately reopen.
|
|
self.ac_dismissed = self.autocomplete.take().map(|a| a.partial);
|
|
}
|
|
if let Some(field) = accept_field {
|
|
self.apply_field_completion(ui.ctx(), &field);
|
|
}
|
|
|
|
let output = egui::TextEdit::multiline(&mut self.buffer)
|
|
.id(egui::Id::new(EDITOR_ID))
|
|
.code_editor()
|
|
.desired_width(f32::INFINITY)
|
|
.desired_rows(30)
|
|
.layouter(&mut layouter)
|
|
.show(ui);
|
|
if output.response.changed() {
|
|
self.dirty = true;
|
|
// The buffer changed, so search matches and the live spell
|
|
// check both need refreshing (the latter debounced).
|
|
self.find_needs_refresh = true;
|
|
self.spell_dirty = true;
|
|
self.spell_last_edit = Some(Instant::now());
|
|
}
|
|
// Markdown formatting hotkeys, applied to the current selection
|
|
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
|
|
// Ctrl/Cmd+Shift+X for strikethrough).
|
|
if output.response.has_focus() {
|
|
if let Some(fmt) = ui.input_mut(detect_format_hotkey) {
|
|
let sel = output
|
|
.cursor_range
|
|
.map(|cr| cr.as_sorted_char_range())
|
|
.unwrap_or(0..0);
|
|
let (new_text, new_sel) = apply_format(&self.buffer, sel, fmt);
|
|
self.buffer = new_text;
|
|
self.dirty = true;
|
|
// Restore the caret/selection around the change.
|
|
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),
|
|
)));
|
|
state.store(ui.ctx(), output.response.id);
|
|
ui.ctx().request_repaint();
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
if let Some(&(s, _)) = self.find_matches.get(self.find_active) {
|
|
let char_idx = self
|
|
.buffer
|
|
.get(..s)
|
|
.map(|p| p.chars().count())
|
|
.unwrap_or(0);
|
|
let local =
|
|
output.galley.pos_from_ccursor(egui::text::CCursor::new(char_idx));
|
|
let onscreen = local.translate(output.galley_pos.to_vec2());
|
|
ui.scroll_to_rect(onscreen, Some(egui::Align::Center));
|
|
}
|
|
self.find_scroll = false;
|
|
}
|
|
|
|
// Right-click a misspelling → a menu of suggested corrections.
|
|
if output.response.secondary_clicked() {
|
|
self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| {
|
|
let local = pos - output.galley_pos;
|
|
let cursor = output.galley.cursor_from_pos(local);
|
|
let byte = char_to_byte(&self.buffer, cursor.ccursor.index);
|
|
self.match_index_at(byte)
|
|
});
|
|
}
|
|
// Snapshot the target's suggestions (owned) so the menu closure
|
|
// doesn't borrow self; compute them on demand if the background
|
|
// check hadn't produced any for this word.
|
|
let menu: Option<(usize, Vec<String>)> = self.spell_menu.and_then(|i| {
|
|
let m = self.current_matches().get(i)?;
|
|
let mut reps = m.replacements.clone();
|
|
if reps.is_empty() {
|
|
if let (Some(dict), Some(word)) =
|
|
(&self.spell_dict, self.buffer.get(m.start..m.end))
|
|
{
|
|
reps = crate::spell::suggestions(dict, word);
|
|
}
|
|
}
|
|
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() => {
|
|
ui.label(egui::RichText::new("Suggestions").strong());
|
|
for (j, rep) in reps.iter().enumerate() {
|
|
if ui.button(rep).clicked() {
|
|
chosen = Some((*i, j));
|
|
ui.close_menu();
|
|
}
|
|
}
|
|
}
|
|
Some(_) => {
|
|
ui.label(egui::RichText::new("No suggestions").weak());
|
|
}
|
|
None => {
|
|
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;
|
|
}
|
|
|
|
// Recompute the header-field autocomplete from the caret so the
|
|
// popup tracks what the user is typing (only for a collapsed
|
|
// caret in the focused editor).
|
|
if output.response.has_focus() {
|
|
let caret = output.cursor_range.and_then(|cr| {
|
|
let r = cr.as_sorted_char_range();
|
|
(r.start == r.end).then_some(r.end)
|
|
});
|
|
let mut next = None;
|
|
let mut still_dismissed = false;
|
|
if let Some(caret_char) = caret {
|
|
let caret_byte = char_to_byte(&self.buffer, caret_char);
|
|
if let Some((field_start, partial)) = header_completion_context(
|
|
&self.buffer,
|
|
caret_byte,
|
|
&self.config.draft_marker,
|
|
) {
|
|
let partial_l = partial.to_lowercase();
|
|
if self.ac_dismissed.as_deref() == Some(partial_l.as_str()) {
|
|
// Esc-dismissed for exactly this partial; stay closed.
|
|
still_dismissed = true;
|
|
} else {
|
|
let matches = field_matches(&self.field_names, &partial);
|
|
if !matches.is_empty() {
|
|
// Keep the highlight while typing the same prefix.
|
|
let selected = match &self.autocomplete {
|
|
Some(prev)
|
|
if prev.partial == partial_l
|
|
&& prev.selected < matches.len() =>
|
|
{
|
|
prev.selected
|
|
}
|
|
_ => 0,
|
|
};
|
|
let caret_rect = output
|
|
.galley
|
|
.pos_from_ccursor(egui::text::CCursor::new(caret_char));
|
|
let anchor = caret_rect.left_bottom()
|
|
+ output.galley_pos.to_vec2()
|
|
+ egui::vec2(0.0, 2.0);
|
|
next = Some(Autocomplete {
|
|
matches,
|
|
selected,
|
|
partial: partial_l,
|
|
field_start,
|
|
cursor: caret_byte,
|
|
anchor,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.autocomplete = next;
|
|
if !still_dismissed {
|
|
self.ac_dismissed = None;
|
|
}
|
|
} else {
|
|
self.autocomplete = None;
|
|
}
|
|
});
|
|
|
|
// Header-field autocomplete popup, floating just below the caret.
|
|
let mut clicked_field: Option<String> = None;
|
|
if let Some(ac) = &self.autocomplete {
|
|
egui::Area::new(egui::Id::new("field_autocomplete"))
|
|
.order(egui::Order::Foreground)
|
|
.fixed_pos(ac.anchor)
|
|
.constrain(true)
|
|
.show(ui.ctx(), |ui| {
|
|
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
|
ui.set_max_width(260.0);
|
|
for (i, m) in ac.matches.iter().enumerate() {
|
|
if ui.selectable_label(i == ac.selected, m).clicked() {
|
|
clicked_field = Some(m.clone());
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
if let Some(field) = clicked_field {
|
|
self.apply_field_completion(ui.ctx(), &field);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build the editor's laid-out text, underlining grammar/spelling matches and
|
|
/// shading search matches.
|
|
///
|
|
/// `grammar` are `(start_byte, end_byte, is_spelling)` triples: spelling issues
|
|
/// get a red underline, grammar/style issues a blue one. `find` are
|
|
/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent
|
|
/// highlight; the one at index `find_active` (if any) is shaded more strongly.
|
|
pub(super) fn build_editor_job(
|
|
text: &str,
|
|
size: f32,
|
|
color: egui::Color32,
|
|
grammar: &[(usize, usize, bool)],
|
|
find: &[(usize, usize)],
|
|
find_active: Option<usize>,
|
|
wrap_width: f32,
|
|
) -> egui::text::LayoutJob {
|
|
use egui::text::{LayoutJob, TextFormat};
|
|
let font_id = egui::FontId::monospace(size);
|
|
let mut job = LayoutJob::default();
|
|
job.wrap.max_width = wrap_width;
|
|
|
|
if grammar.is_empty() && find.is_empty() {
|
|
job.append(text, 0.0, TextFormat::simple(font_id, color));
|
|
return job;
|
|
}
|
|
|
|
// Split the text at every match boundary (grammar and find), then format
|
|
// each run according to which ranges cover it.
|
|
let mut points: Vec<usize> = vec![0, text.len()];
|
|
for &(s, e, _) in grammar {
|
|
points.push(s);
|
|
points.push(e);
|
|
}
|
|
for &(s, e) in find {
|
|
points.push(s);
|
|
points.push(e);
|
|
}
|
|
points.retain(|&p| p <= text.len() && text.is_char_boundary(p));
|
|
points.sort_unstable();
|
|
points.dedup();
|
|
|
|
let spell_color = egui::Color32::from_rgb(0xE0, 0x40, 0x40);
|
|
let grammar_color = egui::Color32::from_rgb(0x3B, 0x82, 0xF6);
|
|
// Translucent so the highlight reads in both light and dark themes.
|
|
let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70);
|
|
let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150);
|
|
let width = (size * 0.08).max(1.5);
|
|
|
|
for w in points.windows(2) {
|
|
let (a, b) = (w[0], w[1]);
|
|
if a >= b {
|
|
continue;
|
|
}
|
|
let mut fmt = TextFormat::simple(font_id.clone(), color);
|
|
if let Some(&(_, _, spelling)) =
|
|
grammar.iter().find(|&&(s, e, _)| s < e && a >= s && b <= e)
|
|
{
|
|
let c = if spelling { spell_color } else { grammar_color };
|
|
fmt.underline = egui::Stroke::new(width, c);
|
|
}
|
|
if let Some(i) = find
|
|
.iter()
|
|
.position(|&(s, e)| s < e && a >= s && b <= e)
|
|
{
|
|
fmt.background = if Some(i) == find_active {
|
|
find_active_bg
|
|
} else {
|
|
find_bg
|
|
};
|
|
}
|
|
job.append(&text[a..b], 0.0, fmt);
|
|
}
|
|
job
|
|
}
|
|
|
|
/// An inline markdown formatting action triggered by an editor hotkey.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub(super) enum Fmt {
|
|
Bold,
|
|
Italic,
|
|
Code,
|
|
Strike,
|
|
Link,
|
|
}
|
|
|
|
impl Fmt {
|
|
/// The `(prefix, suffix)` markers wrapped around the selection.
|
|
fn markers(self) -> (&'static str, &'static str) {
|
|
match self {
|
|
Fmt::Bold => ("**", "**"),
|
|
Fmt::Italic => ("*", "*"),
|
|
Fmt::Code => ("`", "`"),
|
|
Fmt::Strike => ("~~", "~~"),
|
|
Fmt::Link => ("[", "](url)"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Map a formatting hotkey (if pressed this frame) to its action, consuming the
|
|
/// key event so nothing else reacts to it. Cmd+Shift+X is checked first because
|
|
/// `consume_key` ignores an *extra* Shift, so a bare Cmd+X test would swallow it.
|
|
pub(super) fn detect_format_hotkey(input: &mut egui::InputState) -> Option<Fmt> {
|
|
use egui::{Key, Modifiers};
|
|
let cmd = Modifiers::COMMAND;
|
|
if input.consume_key(cmd | Modifiers::SHIFT, Key::X) {
|
|
return Some(Fmt::Strike);
|
|
}
|
|
if input.consume_key(cmd, Key::B) {
|
|
return Some(Fmt::Bold);
|
|
}
|
|
if input.consume_key(cmd, Key::I) {
|
|
return Some(Fmt::Italic);
|
|
}
|
|
if input.consume_key(cmd, Key::E) {
|
|
return Some(Fmt::Code);
|
|
}
|
|
if input.consume_key(cmd, Key::K) {
|
|
return Some(Fmt::Link);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Byte offset of the `char_idx`-th character in `s` (or `s.len()` if past the end).
|
|
pub(super) fn char_to_byte(s: &str, char_idx: usize) -> usize {
|
|
s.char_indices()
|
|
.nth(char_idx)
|
|
.map(|(b, _)| b)
|
|
.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).
|
|
///
|
|
/// Symmetric markers toggle: if the selection is already wrapped — markers just
|
|
/// inside it, or immediately surrounding it — the markers are removed; otherwise
|
|
/// they are added (an empty selection just drops the markers with the caret
|
|
/// between them). A link wraps the selection as the link text and selects the
|
|
/// `url` placeholder so it can be replaced.
|
|
pub(super) fn apply_format(
|
|
text: &str,
|
|
sel: std::ops::Range<usize>,
|
|
fmt: Fmt,
|
|
) -> (String, std::ops::Range<usize>) {
|
|
let (prefix, suffix) = fmt.markers();
|
|
let start = sel.start.min(sel.end);
|
|
let end = sel.start.max(sel.end);
|
|
let b_start = char_to_byte(text, start);
|
|
let b_end = char_to_byte(text, end);
|
|
let selected = &text[b_start..b_end];
|
|
let plen = prefix.chars().count();
|
|
let slen = suffix.chars().count();
|
|
|
|
if fmt == Fmt::Link {
|
|
let new = format!(
|
|
"{}{}{}{}{}",
|
|
&text[..b_start], prefix, selected, suffix, &text[b_end..]
|
|
);
|
|
// The url placeholder sits after "](" (2 chars) inside the suffix.
|
|
let url_start = start + plen + selected.chars().count() + 2;
|
|
return (new, url_start..url_start + 3);
|
|
}
|
|
|
|
// Toggle: markers immediately inside the selection.
|
|
if selected.starts_with(prefix)
|
|
&& selected.ends_with(suffix)
|
|
&& selected.len() >= prefix.len() + suffix.len()
|
|
{
|
|
let inner = &selected[prefix.len()..selected.len() - suffix.len()];
|
|
let new = format!("{}{}{}", &text[..b_start], inner, &text[b_end..]);
|
|
return (new, start..(end - plen - slen));
|
|
}
|
|
|
|
// Toggle: markers immediately surrounding the selection.
|
|
if text[..b_start].ends_with(prefix) && text[b_end..].starts_with(suffix) {
|
|
let new = format!(
|
|
"{}{}{}",
|
|
&text[..b_start - prefix.len()],
|
|
selected,
|
|
&text[b_end + suffix.len()..]
|
|
);
|
|
return (new, (start - plen)..(end - plen));
|
|
}
|
|
|
|
// Wrap.
|
|
let new = format!(
|
|
"{}{}{}{}{}",
|
|
&text[..b_start], prefix, selected, suffix, &text[b_end..]
|
|
);
|
|
if start == end {
|
|
(new, (start + plen)..(start + plen))
|
|
} else {
|
|
(new, (start + plen)..(end + plen))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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);
|
|
assert_eq!(text, "a **word** here");
|
|
// The original "word" stays selected, shifted past the "**".
|
|
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_selection_inserts_markers_with_caret_between() {
|
|
let (text, sel) = apply_format("ab", 1..1, Fmt::Italic);
|
|
assert_eq!(text, "a**b");
|
|
// Caret lands between the two "*", i.e. char index 2.
|
|
assert_eq!(sel, 2..2);
|
|
}
|
|
|
|
#[test]
|
|
fn re_applying_bold_inside_selection_unwraps_it() {
|
|
// Selecting "**word**" and hitting bold again removes the markers.
|
|
let (text, sel) = apply_format("a **word** here", 2..10, Fmt::Bold);
|
|
assert_eq!(text, "a word here");
|
|
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word");
|
|
}
|
|
|
|
#[test]
|
|
fn bold_unwraps_when_markers_surround_the_selection() {
|
|
// Selecting just "word" inside **word** and hitting bold strips them.
|
|
let (text, sel) = apply_format("a **word** here", 4..8, Fmt::Bold);
|
|
assert_eq!(text, "a word here");
|
|
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word");
|
|
}
|
|
|
|
#[test]
|
|
fn link_wraps_selection_and_selects_the_url_placeholder() {
|
|
let (text, sel) = apply_format("see docs now", 4..8, Fmt::Link);
|
|
assert_eq!(text, "see [docs](url) now");
|
|
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "url");
|
|
}
|
|
|
|
#[test]
|
|
fn format_respects_multibyte_char_offsets() {
|
|
// "café " is 5 chars but 6 bytes; selecting "word" (chars 5..9) must
|
|
// still land on the right bytes.
|
|
let (text, sel) = apply_format("café word", 5..9, Fmt::Code);
|
|
assert_eq!(text, "café `word`");
|
|
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word");
|
|
}
|
|
}
|