Skip to main content

egui_charts/widget/
posts.rs

1//! Post and idea content that the host resolves from the links of drawings.
2
3use super::{Chart, ChartEvent};
4use crate::data::{FeedError, inbox::Inbox};
5use crate::drawings::DrawingToolType;
6use crate::model::PostContent;
7use crate::services::Posts;
8use std::sync::{Arc, Mutex, PoisonError};
9
10pub(super) struct PostSource {
11    source: Arc<dyn Posts>,
12    inbox: Arc<Inbox>,
13    /// Links asked for and not answered yet, by request id.
14    pending: Vec<(u64, String)>,
15    next_request: u64,
16    wake: Arc<Mutex<egui::ViewportId>>,
17}
18
19impl Chart {
20    /// Connects the host service that resolves the links of post and idea drawings. The chart
21    /// asks once for each link whose content it lacks, gives the answer to every drawing with
22    /// that link and saves it with them; it never fetches a link itself. Without a source, or
23    /// after a failed answer, the card says the link could not be loaded; setting a source
24    /// asks for those links again. Replacing or removing the source drops pending answers.
25    pub fn set_post_source(&mut self, source: Option<Arc<dyn Posts>>) {
26        self.post_source = source.map(|source| PostSource {
27            source,
28            inbox: Arc::default(),
29            pending: Vec::new(),
30            next_request: 0,
31            wake: Arc::new(Mutex::new(egui::ViewportId::ROOT)),
32        });
33        for manager in std::iter::once(&mut self.drawings).chain(
34            self.symbol_drawings
35                .values_mut()
36                .map(|(manager, _)| manager),
37        ) {
38            for drawing in &mut manager.drawings {
39                if let Some(post) = &mut drawing.post {
40                    post.failed = false;
41                }
42            }
43        }
44    }
45
46    pub(super) fn pump_posts(&mut self, ctx: &egui::Context) {
47        let Some(source) = &mut self.post_source else {
48            for drawing in &mut self.drawings.drawings {
49                if let Some(post) = &mut drawing.post {
50                    post.failed |= post.content.is_none();
51                }
52            }
53            return;
54        };
55        *source.wake.lock().unwrap_or_else(PoisonError::into_inner) = ctx.viewport_id();
56        for (request, answer) in source.inbox.drain().answers {
57            let Some(index) = source.pending.iter().position(|(id, _)| *id == request) else {
58                continue;
59            };
60            let (_, link) = source.pending.swap_remove(index);
61            let answer = *answer.downcast::<Result<PostContent, FeedError>>().unwrap();
62            if self.drawings.resolve_link(&link, answer.ok().as_ref()) {
63                // Saved like an edit, but never undone: undoing would only ask the host again.
64                self.history.drawing_edits += 1;
65            }
66        }
67        for drawing in &self.drawings.drawings {
68            if !matches!(
69                drawing.tool_type,
70                DrawingToolType::Tweet | DrawingToolType::Idea
71            ) {
72                continue;
73            }
74            let Some(post) = &drawing.post else {
75                continue;
76            };
77            if post.content.is_some()
78                || post.failed
79                || source.pending.iter().any(|(_, link)| link == &post.link)
80            {
81                continue;
82            }
83            let (ctx, wake) = (ctx.clone(), Arc::clone(&source.wake));
84            source.inbox.set_waker(move || {
85                let viewport = *wake.lock().unwrap_or_else(PoisonError::into_inner);
86                ctx.request_repaint_of(viewport);
87            });
88            let id = source.next_request;
89            source.next_request += 1;
90            source.pending.push((id, post.link.clone()));
91            source.source.resolve(&post.link, source.inbox.reply(id));
92        }
93    }
94
95    pub(super) fn click_link(&mut self, ctx: &egui::Context, pos: egui::Pos2) {
96        if self.drawings.active_tool.is_some() || self.drawings.eraser_mode {
97            return;
98        }
99        if let Some(id) = self.drawings.hit_test(pos)
100            && let Some(drawing) = self
101                .drawings
102                .drawings
103                .iter()
104                .find(|drawing| drawing.id == id)
105            && let Some(post) = &drawing.post
106            && drawing.open_row(ctx).is_some_and(|row| row.contains(pos))
107        {
108            self.host_events.push(ChartEvent::LinkClick {
109                drawing: id,
110                link: post.link.clone(),
111            });
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::data::Reply;
120    use crate::drawings::persistence::DrawingStorage;
121    use crate::drawings::{ChartPoint, Drawing};
122    use crate::model::PostLink;
123    use crate::services::{Storage, StoreKind};
124    use crate::widget::test_frames::{chart, frame, shapes};
125    use chrono::{TimeZone, Utc};
126    use egui::{Context, Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape};
127    use std::collections::HashMap;
128
129    #[derive(Default)]
130    struct Source(Mutex<Vec<(String, Reply<PostContent>)>>);
131
132    impl Posts for Source {
133        fn resolve(&self, link: &str, reply: Reply<PostContent>) {
134            self.0.lock().unwrap().push((link.into(), reply));
135        }
136    }
137
138    #[derive(Default)]
139    struct Documents(Mutex<HashMap<String, String>>);
140
141    impl Storage for Documents {
142        fn list(&self, _: StoreKind, reply: Reply<Vec<String>>) {
143            reply.send(Ok(Vec::new()));
144        }
145        fn load(&self, _: StoreKind, name: &str, reply: Reply<Option<String>>) {
146            reply.send(Ok(self.0.lock().unwrap().get(name).cloned()));
147        }
148        fn save(&self, _: StoreKind, name: &str, document: String, reply: Reply<()>) {
149            self.0.lock().unwrap().insert(name.into(), document);
150            reply.send(Ok(()));
151        }
152        fn delete(&self, _: StoreKind, _: &str, reply: Reply<()>) {
153            reply.send(Ok(()));
154        }
155    }
156
157    fn content() -> PostContent {
158        PostContent::new(
159            "Ada",
160            Utc.with_ymd_and_hms(2026, 10, 30, 9, 30, 0).unwrap(),
161            "Rates hold",
162        )
163    }
164
165    /// One request fills every matching card, including locked cards, and saves through both
166    /// document storage and per-drawing sync. Reloading asks only for links that failed.
167    #[test]
168    fn links_resolve_once_and_their_content_is_saved() {
169        let ctx = Context::default();
170        let documents = Arc::new(Documents::default());
171        let source = Arc::new(Source::default());
172        let mut chart = chart(&[95.0], &[105.0]);
173        chart.set_symbol("A");
174        chart.set_storage(documents.clone());
175        chart.set_post_source(Some(source.clone()));
176        frame(&ctx, &mut chart, 0.0, vec![]);
177        let (a, b) = ("https://example.invalid/a", "https://example.invalid/b");
178        for (tool, link, locked) in [
179            (DrawingToolType::Tweet, a, false),
180            (DrawingToolType::Idea, a, true),
181            (DrawingToolType::Tweet, b, false),
182        ] {
183            let mut drawing = Drawing::new(0, tool);
184            drawing.chart_points.push(ChartPoint::new(40.0, 100.0));
185            drawing.post = Some(PostLink::new(link));
186            drawing.locked = locked;
187            chart.user_action(|chart| chart.drawings_mut().add_drawing(drawing).unwrap());
188        }
189        frame(&ctx, &mut chart, 0.1, vec![]);
190        let requests = std::mem::take(&mut *source.0.lock().unwrap());
191        assert_eq!(
192            requests
193                .iter()
194                .map(|(link, _)| link.as_str())
195                .collect::<Vec<_>>(),
196            [a, b]
197        );
198        chart.drawings_mut().set_sync_enabled(true);
199        let edits = chart.history.drawing_edits;
200        for (link, reply) in requests {
201            if link == a {
202                reply.send(Ok(content()));
203            }
204        }
205        let painted = shapes(&ctx, &mut chart, 0.2, vec![]);
206        assert!(
207            painted
208                .iter()
209                .any(|shape| matches!(shape, Shape::Text(text) if text.galley.text() == "Ada"))
210        );
211        assert_eq!(chart.history.drawing_edits, edits + 1);
212        assert_eq!(
213            chart
214                .drawings
215                .pending_sync
216                .lock()
217                .unwrap()
218                .iter()
219                .map(|action| {
220                    match action {
221                        crate::drawings::manager::DrawingSyncAction::UpdateDrawing(id) => *id,
222                        other => panic!("expected an update, got {other:?}"),
223                    }
224                })
225                .collect::<Vec<_>>(),
226            chart.drawings.drawings[..2]
227                .iter()
228                .map(|drawing| drawing.id)
229                .collect::<Vec<_>>()
230        );
231        frame(&ctx, &mut chart, 0.3, vec![]);
232        frame(&ctx, &mut chart, 0.4, vec![]);
233        assert!(source.0.lock().unwrap().is_empty());
234        for drawing in &chart.drawings.drawings[..2] {
235            assert_eq!(drawing.post, Some(PostLink::resolved(a, content())));
236        }
237        let failed = chart.drawings.drawings[2].post.as_ref().unwrap();
238        assert!(failed.failed && failed.content.is_none());
239        let saved = documents.0.lock().unwrap()["A"].clone();
240        let (stored, skipped) = DrawingStorage::from_json(&saved).unwrap();
241        assert!(skipped.is_empty());
242        for drawing in &stored.drawings[..2] {
243            assert_eq!(drawing.drawing.post, Some(PostLink::resolved(a, content())));
244        }
245        assert_eq!(stored.drawings[2].drawing.post, Some(PostLink::new(b)));
246        let new_source = Arc::new(Source::default());
247        let mut restored = crate::widget::test_frames::chart(&[95.0], &[105.0]);
248        restored.set_symbol("A");
249        restored.set_storage(documents);
250        restored.set_post_source(Some(new_source.clone()));
251        let ctx = Context::default();
252        for i in 0..3 {
253            frame(&ctx, &mut restored, i as f64, vec![]);
254        }
255        assert_eq!(
256            new_source
257                .0
258                .lock()
259                .unwrap()
260                .iter()
261                .map(|(link, _)| link.as_str())
262                .collect::<Vec<_>>(),
263            [b]
264        );
265        assert_eq!(
266            restored.drawings.drawings[0].post,
267            Some(PostLink::resolved(a, content()))
268        );
269        let (_, obsolete) = new_source.0.lock().unwrap().pop().unwrap();
270        restored.set_post_source(None);
271        frame(&ctx, &mut restored, 3.0, vec![]);
272        assert!(restored.drawings.drawings[2].post.as_ref().unwrap().failed);
273        restored.set_post_source(Some(new_source.clone()));
274        frame(&ctx, &mut restored, 4.0, vec![]);
275        let (_, current) = new_source.0.lock().unwrap().pop().unwrap();
276        obsolete.send(Ok(content()));
277        frame(&ctx, &mut restored, 5.0, vec![]);
278        assert!(
279            restored.drawings.drawings[2]
280                .post
281                .as_ref()
282                .unwrap()
283                .content
284                .is_none()
285        );
286        current.send(Err(FeedError::NotSupported));
287        frame(&ctx, &mut restored, 6.0, vec![]);
288        assert!(restored.drawings.drawings[2].post.as_ref().unwrap().failed);
289        restored.set_post_source(Some(new_source.clone()));
290        frame(&ctx, &mut restored, 7.0, vec![]);
291        let (_, retry) = new_source.0.lock().unwrap().pop().unwrap();
292        retry.send(Ok(content()));
293        frame(&ctx, &mut restored, 8.0, vec![]);
294        assert_eq!(
295            restored.drawings.drawings[2].post,
296            Some(PostLink::resolved(b, content()))
297        );
298    }
299
300    fn button(pos: Pos2, pressed: bool) -> Event {
301        Event::PointerButton {
302            pos,
303            button: PointerButton::Primary,
304            pressed,
305            modifiers: Modifiers::NONE,
306        }
307    }
308
309    fn key(key: Key, modifiers: Modifiers) -> Event {
310        Event::Key {
311            key,
312            physical_key: None,
313            pressed: true,
314            repeat: false,
315            modifiers,
316        }
317    }
318
319    /// Only one address, typed with a resolver attached, replaces hand-typed content with a link.
320    #[test]
321    fn typed_addresses_become_links_only_with_a_post_source() {
322        let address = "https://example.invalid/p/1";
323        for (attached, multiline) in [(true, false), (true, true), (false, false)] {
324            let ctx = Context::default();
325            let source = Arc::new(Source::default());
326            let mut chart = chart(&[95.0], &[105.0]);
327            if attached {
328                chart.set_post_source(Some(source.clone()));
329            }
330            frame(&ctx, &mut chart, 0.0, vec![]);
331            chart
332                .drawings_mut()
333                .set_active_tool(Some(DrawingToolType::Tweet));
334            let pos = chart.get_rendered_price_rect().center();
335            frame(&ctx, &mut chart, 0.1, vec![Event::PointerMoved(pos)]);
336            frame(&ctx, &mut chart, 0.2, vec![button(pos, true)]);
337            frame(&ctx, &mut chart, 0.3, vec![button(pos, false)]);
338            let mut input = Vec::new();
339            if multiline {
340                input.extend([Event::Text("Ada".into()), key(Key::Enter, Modifiers::NONE)]);
341            }
342            input.push(Event::Text(address.into()));
343            frame(&ctx, &mut chart, 0.4, input);
344            frame(
345                &ctx,
346                &mut chart,
347                0.5,
348                vec![key(Key::Enter, Modifiers::COMMAND)],
349            );
350            let drawing = &chart.drawings.drawings[0];
351            if attached && !multiline {
352                assert_eq!(drawing.post, Some(PostLink::new(address)));
353                assert_eq!(drawing.text, None);
354                frame(&ctx, &mut chart, 0.6, vec![]);
355                assert_eq!(source.0.lock().unwrap()[0].0, address);
356            } else {
357                assert_eq!(drawing.post, None);
358                assert_eq!(
359                    drawing.text,
360                    Some(if multiline {
361                        format!("Ada\n{address}")
362                    } else {
363                        address.into()
364                    })
365                );
366                assert!(source.0.lock().unwrap().is_empty());
367            }
368        }
369    }
370
371    /// A resolved entry is placed intact without an editor, and only the Open row reports a
372    /// link click, once for a double click and never while a drawing tool is active.
373    #[test]
374    fn a_placed_card_keeps_its_post_and_its_open_row_reports_it() {
375        let ctx = Context::default();
376        let mut chart = chart(&[95.0], &[105.0]);
377        frame(&ctx, &mut chart, 0.0, vec![]);
378        let mut content = content();
379        content.title = "Breakout".into();
380        let post = PostLink::resolved("https://example.invalid/a", content);
381        chart
382            .drawings_mut()
383            .start_post_insertion(DrawingToolType::Idea, post.clone());
384        let rect = chart.get_rendered_price_rect();
385        let pos = rect.min + rect.size() * egui::vec2(0.2, 0.3);
386        frame(&ctx, &mut chart, 0.1, vec![Event::PointerMoved(pos)]);
387        frame(&ctx, &mut chart, 0.2, vec![button(pos, true)]);
388        let painted = shapes(&ctx, &mut chart, 0.3, vec![button(pos, false)]);
389        assert_eq!(chart.drawings.drawings.len(), 1);
390        let drawing = &chart.drawings.drawings[0];
391        assert_eq!(drawing.tool_type, DrawingToolType::Idea);
392        assert_eq!(drawing.post, Some(post.clone()));
393        let id = drawing.id;
394        assert!(chart.drawings.text_editor.is_none());
395        let text_pos = |label| {
396            painted
397                .iter()
398                .find_map(|shape| match shape {
399                    Shape::Text(text) if text.galley.text() == label => {
400                        Some(Rect::from_min_size(text.pos, text.galley.size()).center())
401                    }
402                    _ => None,
403                })
404                .unwrap()
405        };
406        let (title, open) = (text_pos("Breakout"), text_pos("Open"));
407        chart.drain_events().for_each(drop);
408        let mut time = 1.0;
409        let mut click = |chart: &mut Chart, pos| {
410            time += 0.1;
411            frame(
412                &ctx,
413                chart,
414                time,
415                vec![Event::PointerMoved(pos), button(pos, true)],
416            );
417            time += 0.1;
418            frame(&ctx, chart, time, vec![button(pos, false)]);
419            chart
420                .drain_events()
421                .filter(|event| matches!(event, ChartEvent::LinkClick { .. }))
422                .collect::<Vec<_>>()
423        };
424        chart
425            .drawings_mut()
426            .set_active_tool(Some(DrawingToolType::TrendLine));
427        assert!(click(&mut chart, open).is_empty());
428        chart.drawings_mut().set_active_tool(None);
429        assert!(click(&mut chart, title).is_empty());
430        let expected = vec![ChartEvent::LinkClick {
431            drawing: id,
432            link: post.link,
433        }];
434        assert_eq!(click(&mut chart, open), expected);
435        // Let the previous click expire before starting the double click.
436        time += 1.0;
437        frame(&ctx, &mut chart, time, vec![]);
438        let mut events = Vec::new();
439        for i in 1..=4 {
440            frame(
441                &ctx,
442                &mut chart,
443                time + i as f64 * 0.05,
444                vec![Event::PointerMoved(open), button(open, i % 2 == 1)],
445            );
446            events.extend(
447                chart
448                    .drain_events()
449                    .filter(|event| matches!(event, ChartEvent::LinkClick { .. })),
450            );
451        }
452        assert_eq!(events, expected);
453        assert!(chart.drawings.text_editor.is_some());
454    }
455}