Skip to main content

odox_ui/
find_bar.rs

1//! The bar a search is typed into, under the menu: the query, how many matches
2//! there are, and the way from one to the next. It holds what a person typed
3//! and nothing of the document; the shell asks the view what was found.
4//
5// Author: David M. Anderson
6// Built with AI assistance (Claude, Anthropic)
7
8use eframe::egui::{self, Key, Ui, text::CCursor, text::CCursorRange};
9
10use crate::i18n::{fill, t};
11
12/// Which way to move among the matches.
13#[derive(Clone, Copy, PartialEq, Eq)]
14pub enum Step {
15    /// The match after this one, from the last round to the first.
16    Next,
17    /// The match before this one, from the first round to the last.
18    Previous,
19}
20
21/// Which replace button was pressed.
22#[derive(Clone, Copy, PartialEq, Eq)]
23pub enum Replace {
24    /// The current match.
25    One,
26    /// Every match.
27    All,
28}
29
30/// What was pressed on the bar this frame.
31#[derive(Default)]
32pub struct Pressed {
33    /// A way to move among the matches.
34    pub step: Option<Step>,
35    /// A replace.
36    pub replace: Option<Replace>,
37}
38
39/// Which field of the bar is owed the keyboard.
40#[derive(Default, PartialEq, Eq)]
41enum Focus {
42    #[default]
43    Nowhere,
44    Query,
45    Replacement,
46}
47
48/// The state of the search bar.
49#[derive(Default)]
50pub struct FindBar {
51    open: bool,
52    query: String,
53    /// The field that is to take the keyboard on the next frame it is drawn,
54    /// with what it holds selected.
55    focus: Focus,
56    /// What the view was last asked, so that it is asked again only when the
57    /// query or the document is not what it was.
58    asked: Option<(String, u64)>,
59    /// What replaces a match, and what the last replace said.
60    replacement: String,
61    message: Option<String>,
62    /// How many matches the view reported for what it was last asked.
63    pub(crate) count: usize,
64    /// Which of them is current, counted from zero.
65    pub(crate) current: usize,
66    /// The view holds a search it has not been told to forget.
67    pub(crate) searching: bool,
68}
69
70impl FindBar {
71    /// Show the bar and put the keyboard in it.
72    pub fn open(&mut self) {
73        self.open = true;
74        self.focus = Focus::Query;
75    }
76
77    /// Show the bar and put the keyboard in the replacement, where there is a
78    /// query to replace.
79    pub fn open_replacing(&mut self) {
80        self.open();
81        if !self.query.is_empty() {
82            self.focus = Focus::Replacement;
83        }
84    }
85
86    /// What replaces a match.
87    pub fn replacement(&self) -> &str {
88        &self.replacement
89    }
90
91    /// Say what a replace did, until the search changes.
92    pub fn say(&mut self, message: String) {
93        self.message = Some(message);
94    }
95
96    /// Hide the bar. The query stays for the next time.
97    pub fn close(&mut self) {
98        self.open = false;
99        self.asked = None;
100    }
101
102    /// Whether the bar is showing.
103    pub fn is_open(&self) -> bool {
104        self.open
105    }
106
107    /// What was typed.
108    pub fn query(&self) -> &str {
109        &self.query
110    }
111
112    /// Whether the view has to be asked again, given the document's revision;
113    /// answers `Some(query_changed)` if so, and remembers it was asked.
114    pub fn due(&mut self, revision: u64) -> Option<bool> {
115        let same_query = self.asked.as_ref().is_some_and(|(q, _)| *q == self.query);
116        let same_revision = self.asked.as_ref().is_some_and(|(_, r)| *r == revision);
117        if same_query && same_revision {
118            return None;
119        }
120        self.asked = Some((self.query.clone(), revision));
121        if !same_query {
122            self.message = None;
123        }
124        Some(!same_query)
125    }
126
127    /// Draw the bar, with a row for replacing where the view can.
128    pub fn ui(&mut self, ui: &mut Ui, can_replace: bool) -> Pressed {
129        let (count, current) = (self.count, self.current);
130        let mut pressed = Pressed::default();
131        let step = &mut pressed.step;
132        ui.horizontal(|ui| {
133            let id = egui::Id::new("odox-find");
134            let mut output = egui::TextEdit::singleline(&mut self.query)
135                .id(id)
136                .hint_text(t("Find"))
137                .desired_width(240.0)
138                .show(ui);
139            if self.focus == Focus::Query {
140                self.focus = Focus::Nowhere;
141                output.response.request_focus();
142                output.state.cursor.set_char_range(Some(CCursorRange::two(
143                    CCursor::new(0),
144                    CCursor::new(self.query.chars().count()),
145                )));
146                output.state.store(ui.ctx(), id);
147            }
148            if output.response.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)) {
149                *step = Some(if ui.input(|input| input.modifiers.shift) {
150                    Step::Previous
151                } else {
152                    Step::Next
153                });
154                output.response.request_focus();
155            }
156            if output.response.lost_focus() && ui.input(|input| input.key_pressed(Key::Escape)) {
157                self.close();
158                return;
159            }
160            if ui.button(t("Previous")).clicked() {
161                *step = Some(Step::Previous);
162            }
163            if ui.button(t("Next")).clicked() {
164                *step = Some(Step::Next);
165            }
166            if self.query.is_empty() {
167            } else if count == 0 {
168                ui.colored_label(ui.visuals().warn_fg_color, t("No matches"));
169            } else {
170                ui.label(fill(
171                    t("{current} of {count}"),
172                    &[
173                        ("current", &(current + 1).to_string()),
174                        ("count", &count.to_string()),
175                    ],
176                ));
177            }
178            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
179                if ui.button("×").on_hover_text(t("Close")).clicked() {
180                    self.close();
181                }
182            });
183        });
184        if can_replace && self.open {
185            ui.horizontal(|ui| self.replace_row(ui, count, &mut pressed));
186        }
187        pressed
188    }
189
190    fn replace_row(&mut self, ui: &mut Ui, count: usize, pressed: &mut Pressed) {
191        let id = egui::Id::new("odox-replace");
192        let output = egui::TextEdit::singleline(&mut self.replacement)
193            .id(id)
194            .hint_text(t("Replace with"))
195            .desired_width(240.0)
196            .show(ui);
197        if self.focus == Focus::Replacement {
198            self.focus = Focus::Nowhere;
199            output.response.request_focus();
200        }
201        if output.response.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)) {
202            pressed.replace = Some(Replace::One);
203            output.response.request_focus();
204        }
205        if output.response.lost_focus() && ui.input(|input| input.key_pressed(Key::Escape)) {
206            self.close();
207            return;
208        }
209        if ui
210            .add_enabled(count > 0, egui::Button::new(t("Replace")))
211            .clicked()
212        {
213            pressed.replace = Some(Replace::One);
214        }
215        if ui
216            .add_enabled(count > 0, egui::Button::new(t("Replace all")))
217            .clicked()
218        {
219            pressed.replace = Some(Replace::All);
220        }
221        if let Some(message) = &self.message {
222            ui.label(message);
223        }
224    }
225}