egui_charts/ui/
study_editor.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
19#[non_exhaustive]
20pub enum Severity {
21 Info,
23 Warning,
25 Error,
27}
28
29#[derive(Clone, Debug, PartialEq)]
31pub struct Diagnostic {
32 pub severity: Severity,
34 pub message: String,
36 pub span: Range<usize>,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum StudyEditorAction {
45 None,
47 Open,
50 Save {
53 title: String,
55 text: String,
57 },
58 Apply {
61 title: String,
63 text: String,
65 },
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
70enum Status {
71 Applied,
72 Rejected(String),
73}
74
75#[derive(Default)]
77pub struct StudyEditor {
78 title: String,
79 text: String,
80 saved: String,
82 diagnostics: Vec<Diagnostic>,
83 status: Option<Status>,
84 reveal: Option<Range<usize>>,
86}
87
88impl StudyEditor {
89 pub fn new() -> Self {
91 Self::default()
92 }
93
94 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 pub fn title(&self) -> &str {
106 &self.title
107 }
108
109 pub fn text(&self) -> &str {
111 &self.text
112 }
113
114 pub fn is_modified(&self) -> bool {
116 self.text != self.saved
117 }
118
119 pub fn mark_saved(&mut self, text: &str) {
122 self.saved = text.to_owned();
123 }
124
125 pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>) {
127 self.diagnostics = diagnostics;
128 }
129
130 pub fn diagnostics(&self) -> &[Diagnostic] {
132 &self.diagnostics
133 }
134
135 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 pub fn reveal(&mut self, span: Range<usize>) {
146 self.reveal = Some(span);
147 }
148
149 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 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 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 assert_eq!(editor.char_range(&(1..3)), 1..2);
291 assert_eq!(editor.char_range(&(2..99)), 1..4);
292 }
293}