1use 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 pending: Vec<(u64, String)>,
15 next_request: u64,
16 wake: Arc<Mutex<egui::ViewportId>>,
17}
18
19impl Chart {
20 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 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 #[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 #[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 #[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 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}