idet-core 0.4.1

Editing logic for text editors, without a frontend
Documentation
use egui::{
    Align2, Context, EventFilter, Key, Modifiers, Response, TextEdit as EguiTextEdit, Ui, Window,
    vec2,
};

use crate::search::{find_matches, replace_all, replace_range};

fn hold_tab(ui: &Ui, response: &Response) {
    if response.has_focus() {
        ui.memory_mut(|memory| {
            memory.set_focus_lock_filter(
                response.id,
                EventFilter {
                    tab: true,
                    ..EventFilter::default()
                },
            );
        });
    }
}

fn action(ui: &mut Ui, label: &str) -> bool {
    let response = ui.button(label);
    hold_tab(ui, &response);
    response.clicked()
}

#[derive(Default)]
pub(super) struct Search {
    pub(super) open: bool,
    pub(super) replacing: bool,
    pub(super) term: String,
    pub(super) replacement: String,
    pub(super) last_term: Option<String>,
    pub(super) taking_focus: bool,
}

pub(super) struct SearchOutput {
    pub(super) cursor: Option<(usize, usize)>,
    pub(super) edited: bool,
    pub(super) carets: Vec<usize>,
}

impl Search {
    pub(super) fn open(&mut self, initial: String, replacing: bool) {
        if !initial.is_empty() {
            self.term = initial;
        }
        self.replacing = replacing;
        self.open = true;
        self.taking_focus = true;
    }

    pub(super) fn jump_to_match(
        &self,
        text: &str,
        cursor: usize,
        anchor: usize,
        forward: bool,
    ) -> Option<(usize, usize)> {
        let term = self.last_term.as_ref()?;
        let matches = find_matches(text, term);
        if matches.is_empty() {
            return None;
        }
        let length = term.chars().count();
        let next = if forward {
            matches
                .iter()
                .find(|&&position| position > cursor)
                .or_else(|| matches.first())
        } else {
            let start = anchor.min(cursor);
            matches
                .iter()
                .rev()
                .find(|&&position| position < start)
                .or_else(|| matches.last())
        }?;
        Some((next + length, *next))
    }

    fn replace_current(&self, text: &mut String, cursor: usize, anchor: usize) -> (usize, usize) {
        let start = cursor.min(anchor);
        let length = self.term.chars().count();
        let selected: String = text.chars().skip(start).take(length).collect();
        if cursor == anchor || find_matches(&selected, &self.term).first() != Some(&0) {
            return (cursor, anchor);
        }
        *text = replace_range(text, start, length, &self.replacement);
        let end = start + self.replacement.chars().count();
        (end, end)
    }

    fn fields(&mut self, ui: &mut Ui, text: &str) -> bool {
        let response = ui
            .horizontal(|ui| {
                let (arrow, hint) = if self.replacing {
                    ("", "Find only (Ctrl+F)")
                } else {
                    ("", "Replace as well (Ctrl+H)")
                };
                let toggle = ui.small_button(arrow).on_hover_text(hint);
                hold_tab(ui, &toggle);
                if toggle.clicked() {
                    self.replacing = !self.replacing;
                }
                let field = ui.add(EguiTextEdit::singleline(&mut self.term).desired_width(240.0));
                hold_tab(ui, &field);
                field
            })
            .inner;
        if response.changed() {
            self.last_term = Some(self.term.clone());
        }
        let replacement = self.replacing.then(|| {
            let field =
                ui.add(EguiTextEdit::singleline(&mut self.replacement).desired_width(240.0));
            hold_tab(ui, &field);
            field
        });
        if self.taking_focus {
            response.request_focus();
            self.taking_focus = false;
        }
        if !self.term.is_empty() {
            let matches = find_matches(text, &self.term);
            ui.label(format!("{} match(es)", matches.len()));
        }
        replacement.is_some_and(|field| field.has_focus())
    }

    pub(super) fn draw(
        &mut self,
        ctx: &Context,
        text: &mut String,
        cursor: usize,
        anchor: usize,
        id_source: impl std::hash::Hash + std::fmt::Debug,
    ) -> SearchOutput {
        let mut output = SearchOutput {
            cursor: None,
            edited: false,
            carets: Vec::new(),
        };
        if !self.open {
            return output;
        }
        if ctx.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Escape)) {
            self.open = false;
            return output;
        }
        let mut backward = ctx.input_mut(|input| input.consume_key(Modifiers::SHIFT, Key::Tab));
        let mut forward = ctx.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Tab));
        let entered = ctx.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Enter));
        let every = ctx.input_mut(|input| input.consume_key(Modifiers::CTRL, Key::Enter));
        let mut replace_every = every && self.replacing;
        let mut all_as_carets = every && !self.replacing;
        let mut replace_one = false;
        let mut confirm = false;
        let mut close = false;

        Window::new("Find")
            .id(egui::Id::new("idet-core-search").with(id_source))
            .collapsible(false)
            .resizable(false)
            .anchor(Align2::CENTER_TOP, vec2(0.0, 40.0))
            .show(ctx, |ui| {
                let in_replacement = self.fields(ui, text);
                replace_one |= entered && in_replacement;
                confirm |= entered && !in_replacement;
                ui.horizontal(|ui| {
                    forward |= action(ui, "Next (Tab)");
                    backward |= action(ui, "Previous (Shift+Tab)");
                    if self.replacing {
                        replace_one |= action(ui, "Replace (Enter)");
                        replace_every |= action(ui, "Replace all (Ctrl+Enter)");
                    } else {
                        all_as_carets |= action(ui, "A caret on each (Ctrl+Enter)");
                    }
                    close |= action(ui, "Close (Esc)");
                });
            });

        if forward || backward {
            self.last_term = Some(self.term.clone());
            output.cursor = self.jump_to_match(text, cursor, anchor, forward);
        }
        if replace_one && !self.term.is_empty() {
            self.last_term = Some(self.term.clone());
            let (moved_cursor, moved_anchor) = self.replace_current(text, cursor, anchor);
            output.edited = moved_cursor != cursor || moved_anchor != anchor;
            output.cursor = self
                .jump_to_match(text, moved_cursor, moved_anchor, true)
                .or(Some((moved_cursor, moved_anchor)));
        }
        if replace_every && !self.term.is_empty() {
            let (replaced, count) = replace_all(text, &self.term, &self.replacement);
            if count > 0 {
                *text = replaced;
                output.edited = true;
                let position = cursor.min(text.chars().count());
                output.cursor = Some((position, position));
            }
            self.open = false;
        }
        if all_as_carets && !self.term.is_empty() {
            self.last_term = Some(self.term.clone());
            let length = self.term.chars().count();
            output.carets = find_matches(text, &self.term)
                .into_iter()
                .map(|start| start + length)
                .collect();
            if !output.carets.is_empty() {
                self.open = false;
            }
        }
        if confirm {
            self.last_term = Some(self.term.clone());
            output.cursor = self.jump_to_match(text, cursor, anchor, true);
            self.open = self.replacing;
        }
        if close {
            self.open = false;
        }
        output
    }
}

#[cfg(test)]
mod tests {
    use super::Search;

    fn replacing(term: &str, replacement: &str) -> Search {
        Search {
            open: true,
            replacing: true,
            term: term.to_owned(),
            replacement: replacement.to_owned(),
            last_term: None,
            taking_focus: false,
        }
    }

    #[test]
    fn a_selected_match_is_replaced_and_the_cursor_follows_it() {
        let mut text = "alpha beta".to_owned();
        let search = replacing("alpha", "omega");
        assert_eq!(search.replace_current(&mut text, 5, 0), (5, 5));
        assert_eq!(text, "omega beta");
    }

    #[test]
    fn a_selection_that_is_not_the_term_is_left_alone() {
        let mut text = "alpha beta".to_owned();
        let search = replacing("alpha", "omega");
        assert_eq!(search.replace_current(&mut text, 10, 6), (10, 6));
        assert_eq!(text, "alpha beta");
    }

    #[test]
    fn nothing_selected_replaces_nothing() {
        let mut text = "alpha beta".to_owned();
        let search = replacing("alpha", "omega");
        assert_eq!(search.replace_current(&mut text, 3, 3), (3, 3));
        assert_eq!(text, "alpha beta");
    }

    #[test]
    fn a_shorter_replacement_leaves_the_cursor_at_its_end() {
        let mut text = "alpha beta".to_owned();
        let search = replacing("alpha", "hi");
        assert_eq!(search.replace_current(&mut text, 5, 0), (2, 2));
        assert_eq!(text, "hi beta");
    }
}