Skip to main content

egui_charts/ui/
study_editor.rs

1//! Study editor panel: an editing surface for host-owned study source.
2//!
3//! The panel holds a document title and its text, tells whether the text changed since it was
4//! opened or saved, lists the host's diagnostics and sends open, save and apply requests. The
5//! host owns the language, the files and the study: it compiles the text, answers with
6//! [`set_diagnostics`](StudyEditor::set_diagnostics) and
7//! [`apply_result`](StudyEditor::apply_result), and the edits stay in the panel whatever it
8//! answers. The panel needs no other part of the library and can sit in any layout.
9
10use crate::localization as l10n;
11use crate::ui_kit::buttons::Button;
12use crate::ui_kit::status_message::{MessageKind, status_message};
13use egui::text::{CCursor, CCursorRange};
14use egui::{Align, RichText, ScrollArea, TextEdit, Ui};
15use std::ops::Range;
16
17/// How serious a [`Diagnostic`] is.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
19#[non_exhaustive]
20pub enum Severity {
21    /// A hint.
22    Info,
23    /// The text works but may not do what was meant.
24    Warning,
25    /// The text cannot be applied.
26    Error,
27}
28
29/// A host message about a span of the document.
30#[derive(Clone, Debug, PartialEq)]
31pub struct Diagnostic {
32    /// How serious it is.
33    pub severity: Severity,
34    /// What the host found.
35    pub message: String,
36    /// The byte range of the text it concerns. A range past the text or inside a character is
37    /// clamped to the text and to character boundaries.
38    pub span: Range<usize>,
39}
40
41/// What the user asked of the host.
42#[derive(Clone, Debug, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum StudyEditorAction {
45    /// Nothing was asked.
46    None,
47    /// The user wants to open another document: the host picks one and calls
48    /// [`StudyEditor::open_document`]. Unsaved edits are still in the panel until it does.
49    Open,
50    /// The user wants the document stored: the host saves it and calls
51    /// [`StudyEditor::mark_saved`].
52    Save {
53        /// The document title.
54        title: String,
55        /// The document text.
56        text: String,
57    },
58    /// The user wants the text applied as a study: the host compiles it and calls
59    /// [`StudyEditor::apply_result`].
60    Apply {
61        /// The document title.
62        title: String,
63        /// The document text.
64        text: String,
65    },
66}
67
68/// The host's answer to an apply request.
69#[derive(Clone, Debug, PartialEq, Eq)]
70enum Status {
71    Applied,
72    Rejected(String),
73}
74
75/// The study editor panel.
76#[derive(Default)]
77pub struct StudyEditor {
78    title: String,
79    text: String,
80    /// The text as last opened or saved, to tell whether it was modified.
81    saved: String,
82    diagnostics: Vec<Diagnostic>,
83    status: Option<Status>,
84    /// A span to select and scroll to in the next frame.
85    reveal: Option<Range<usize>>,
86}
87
88impl StudyEditor {
89    /// An empty, untitled document.
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Replaces the document, clearing diagnostics and status and the modified state.
95    pub fn open_document(&mut self, title: impl Into<String>, text: impl Into<String>) {
96        self.title = title.into();
97        self.text = text.into();
98        self.saved = self.text.clone();
99        self.diagnostics.clear();
100        self.status = None;
101        self.reveal = None;
102    }
103
104    /// The document title.
105    pub fn title(&self) -> &str {
106        &self.title
107    }
108
109    /// The document text, with the user's edits.
110    pub fn text(&self) -> &str {
111        &self.text
112    }
113
114    /// Whether the text differs from the text last opened or saved.
115    pub fn is_modified(&self) -> bool {
116        self.text != self.saved
117    }
118
119    /// Records that the host stored `text`, which clears the modified state if the user has not
120    /// edited the document since the save request.
121    pub fn mark_saved(&mut self, text: &str) {
122        self.saved = text.to_owned();
123    }
124
125    /// Replaces the diagnostics shown, most serious first as the host sends them.
126    pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>) {
127        self.diagnostics = diagnostics;
128    }
129
130    /// The diagnostics shown.
131    pub fn diagnostics(&self) -> &[Diagnostic] {
132        &self.diagnostics
133    }
134
135    /// Records the host's answer to [`StudyEditorAction::Apply`]: `Ok` shows the text as applied,
136    /// `Err` shows the reason. Neither changes the text.
137    pub fn apply_result(&mut self, result: Result<(), String>) {
138        self.status = Some(match result {
139            Ok(()) => Status::Applied,
140            Err(reason) => Status::Rejected(reason),
141        });
142    }
143
144    /// Selects the byte `span` in the text and scrolls to it in the next frame.
145    pub fn reveal(&mut self, span: Range<usize>) {
146        self.reveal = Some(span);
147    }
148
149    /// The span as character offsets, clamped to the text.
150    fn char_range(&self, span: &Range<usize>) -> Range<usize> {
151        let at = |byte: usize| {
152            let mut byte = byte.min(self.text.len());
153            while !self.text.is_char_boundary(byte) {
154                byte -= 1;
155            }
156            self.text[..byte].chars().count()
157        };
158        at(span.start)..at(span.end.max(span.start))
159    }
160
161    /// Shows the panel in `ui` and returns what the user asked of the host.
162    pub fn show(&mut self, ui: &mut Ui) -> StudyEditorAction {
163        let mut action = StudyEditorAction::None;
164        let weak = ui.visuals().weak_text_color();
165        ui.horizontal(|ui| {
166            ui.label(RichText::new(l10n::text(ui.ctx(), "Title")).color(weak));
167            ui.add(TextEdit::singleline(&mut self.title).desired_width(220.0));
168            if self.is_modified() {
169                ui.label(RichText::new(l10n::text(ui.ctx(), "Unsaved changes")).color(weak));
170            }
171            ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| {
172                if Button::new("Apply").show(ui).clicked() {
173                    action = StudyEditorAction::Apply {
174                        title: self.title.clone(),
175                        text: self.text.clone(),
176                    };
177                }
178                if Button::new("Save").show(ui).clicked() {
179                    action = StudyEditorAction::Save {
180                        title: self.title.clone(),
181                        text: self.text.clone(),
182                    };
183                }
184                if Button::new("Open").show(ui).clicked() {
185                    action = StudyEditorAction::Open;
186                }
187            });
188        });
189        match &self.status {
190            Some(Status::Applied) => {
191                status_message(ui, MessageKind::Success, &l10n::text(ui.ctx(), "Applied"));
192            }
193            Some(Status::Rejected(reason)) => {
194                status_message(ui, MessageKind::Error, reason);
195            }
196            None => {}
197        }
198        let editor = ui.make_persistent_id("study_editor_text");
199        let height = (ui.available_height() - self.diagnostics_height(ui)).max(80.0);
200        let reveal = self.reveal.take().map(|span| self.char_range(&span));
201        ScrollArea::vertical()
202            .id_salt("study_editor_scroll")
203            .max_height(height)
204            .auto_shrink([false; 2])
205            .show(ui, |ui| {
206                let mut output = TextEdit::multiline(&mut self.text)
207                    .id(editor)
208                    .code_editor()
209                    .desired_width(f32::INFINITY)
210                    .desired_rows(
211                        (height / ui.text_style_height(&egui::TextStyle::Monospace)) as usize,
212                    )
213                    .show(ui);
214                if let Some(range) = reveal {
215                    let cursors =
216                        CCursorRange::two(CCursor::new(range.start), CCursor::new(range.end));
217                    output.state.cursor.set_char_range(Some(cursors));
218                    output.state.store(ui.ctx(), editor);
219                    ui.memory_mut(|memory| memory.request_focus(editor));
220                    let at = output.galley.pos_from_cursor(CCursor::new(range.start));
221                    ui.scroll_to_rect(
222                        at.translate(output.galley_pos.to_vec2()),
223                        Some(Align::Center),
224                    );
225                }
226            });
227        let mut reveal = None;
228        for diagnostic in &self.diagnostics {
229            let color = match diagnostic.severity {
230                Severity::Error => ui.visuals().error_fg_color,
231                Severity::Warning => ui.visuals().warn_fg_color,
232                Severity::Info => weak,
233            };
234            let label = match diagnostic.severity {
235                Severity::Error => "Error",
236                Severity::Warning => "Warning",
237                Severity::Info => "Info",
238            };
239            let line = ui.horizontal(|ui| {
240                ui.label(RichText::new(l10n::text(ui.ctx(), label)).color(color));
241                ui.add(
242                    egui::Label::new(&diagnostic.message)
243                        .sense(egui::Sense::click())
244                        .truncate(),
245                )
246                .clicked()
247            });
248            if line.inner {
249                reveal = Some(diagnostic.span.clone());
250            }
251        }
252        if reveal.is_some() {
253            self.reveal = reveal;
254            ui.ctx().request_repaint();
255        }
256        action
257    }
258
259    /// Room kept below the text for the diagnostics list.
260    fn diagnostics_height(&self, ui: &Ui) -> f32 {
261        let row = ui.spacing().interact_size.y + ui.spacing().item_spacing.y;
262        row * self.diagnostics.len().min(6) as f32
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn a_rejected_apply_keeps_the_edits_and_a_save_clears_the_modified_state() {
272        let mut editor = StudyEditor::new();
273        editor.open_document("RSI cross", "plot(rsi)");
274        assert!(!editor.is_modified());
275        editor.text.push_str("\nplot(sma)");
276        assert!(editor.is_modified());
277        editor.apply_result(Err("line 2: unknown name".into()));
278        assert_eq!(editor.text(), "plot(rsi)\nplot(sma)");
279        assert!(editor.is_modified());
280        let saved = editor.text().to_owned();
281        editor.mark_saved(&saved);
282        assert!(!editor.is_modified());
283    }
284
285    #[test]
286    fn spans_are_clamped_to_characters_of_the_text() {
287        let mut editor = StudyEditor::new();
288        editor.open_document("t", "aé=1");
289        // Bytes: a=0, é=1..3, '='=3, '1'=4.
290        assert_eq!(editor.char_range(&(1..3)), 1..2);
291        assert_eq!(editor.char_range(&(2..99)), 1..4);
292    }
293}