Skip to main content

egui_charts/ui/
ideas.rs

1//! Ideas panel: a composer and a browsing feed for ideas and short posts.
2//!
3//! The host owns accounts, storage and the network. It fills the feed with
4//! [`add_page`](IdeasPanel::add_page), answers [`IdeasAction::Publish`] with
5//! [`publish_succeeded`](IdeasPanel::publish_succeeded) or
6//! [`publish_failed`](IdeasPanel::publish_failed) and opens what the user selects, or places it
7//! on a chart. A control whose service the host did not enable with
8//! [`set_publishing`](IdeasPanel::set_publishing) or [`set_browsing`](IdeasPanel::set_browsing)
9//! is not shown. The draft survives a failed publish, and the panel needs no other part of the
10//! library.
11
12use crate::localization as l10n;
13use crate::model::{PostContent, PostLink};
14use crate::ui_kit::buttons::Button;
15use crate::ui_kit::status_message::{MessageKind, status_message};
16use crate::ui_kit::{EmptyState, PanelHeader};
17use chrono::{DateTime, Utc};
18use egui::{RichText, ScrollArea, TextEdit, Ui};
19
20/// What a feed entry is.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
22#[non_exhaustive]
23pub enum IdeaKind {
24    /// A titled analysis of a symbol, usually with a chart attached.
25    #[default]
26    Idea,
27    /// A short note without a title.
28    ShortPost,
29}
30
31/// An entry of the host's feed.
32#[derive(Clone, Debug, PartialEq)]
33pub struct Idea {
34    /// The host's identifier of the entry.
35    pub id: String,
36    /// Idea or short post.
37    pub kind: IdeaKind,
38    /// The title; empty for a short post.
39    pub title: String,
40    /// The text.
41    pub body: String,
42    /// Who published it.
43    pub author: String,
44    /// When it was published.
45    pub time: DateTime<Utc>,
46    /// The instrument it is about.
47    pub symbol: String,
48    /// The chart interval it was made on, as the host names it.
49    pub interval: String,
50    /// The host's reference to the attached chart image, if any: a link or a key.
51    pub attachment: Option<String>,
52}
53
54/// One page of the host's feed.
55#[derive(Clone, Debug, PartialEq, Default)]
56pub struct IdeaPage {
57    /// The entries, newest first.
58    pub items: Vec<Idea>,
59    /// The host's cursor for the next page, or `None` at the end of the feed.
60    pub next: Option<String>,
61}
62
63/// What the user is composing. It stays in the panel until a publish succeeds.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct IdeaDraft {
66    /// The title.
67    pub title: String,
68    /// The text.
69    pub body: String,
70    /// The instrument it is about; the host sets it from the chart.
71    pub symbol: String,
72    /// The chart interval, as the host names it.
73    pub interval: String,
74    /// Whether to attach an image of the chart; the host takes it with the chart's image export.
75    pub attach_chart: bool,
76}
77
78/// What the user asked of the host.
79#[derive(Clone, Debug, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum IdeasAction {
82    /// Nothing was asked.
83    None,
84    /// Publish the draft, then answer with [`IdeasPanel::publish_succeeded`] or
85    /// [`IdeasPanel::publish_failed`].
86    Publish(IdeaDraft),
87    /// Fetch the page after `cursor` and pass it to [`IdeasPanel::add_page`].
88    LoadMore(String),
89    /// Open the entry with this identifier.
90    Open(String),
91    /// Show the symbol and interval of the entry on the chart.
92    OpenChart {
93        /// The instrument.
94        symbol: String,
95        /// The interval, as the host names it.
96        interval: String,
97    },
98    /// Place the entry on the chart as an idea card: pass the link to
99    /// [`DrawingManager::start_post_insertion`](crate::drawings::DrawingManager::start_post_insertion)
100    /// with [`DrawingToolType::Idea`](crate::drawings::DrawingToolType::Idea), and the next
101    /// click on the chart places it. The link is the entry's identifier, already resolved to its
102    /// title, author, time, text and attachment; the attachment shows as the card's picture when
103    /// the host's egui image loaders resolve it.
104    Place(PostLink),
105}
106
107/// Where a publish stands.
108#[derive(Clone, Debug, PartialEq, Eq, Default)]
109enum Publish {
110    #[default]
111    Idle,
112    Pending,
113    Done {
114        id: String,
115        link: Option<String>,
116    },
117    Failed(String),
118}
119
120/// The ideas panel.
121pub struct IdeasPanel {
122    draft: IdeaDraft,
123    publish: Publish,
124    ideas: Vec<Idea>,
125    next: Option<String>,
126    can_publish: bool,
127    can_browse: bool,
128    /// Which kind the feed shows.
129    filter: IdeaKind,
130}
131
132impl Default for IdeasPanel {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl IdeasPanel {
139    /// A panel with neither service enabled.
140    pub fn new() -> Self {
141        Self {
142            draft: IdeaDraft {
143                title: String::new(),
144                body: String::new(),
145                symbol: String::new(),
146                interval: String::new(),
147                attach_chart: true,
148            },
149            publish: Publish::Idle,
150            ideas: Vec::new(),
151            next: None,
152            can_publish: false,
153            can_browse: false,
154            filter: IdeaKind::Idea,
155        }
156    }
157
158    /// Shows the composer, or hides it when `available` is false.
159    pub fn set_publishing(&mut self, available: bool) {
160        self.can_publish = available;
161    }
162
163    /// Shows the feed, or hides it when `available` is false.
164    pub fn set_browsing(&mut self, available: bool) {
165        self.can_browse = available;
166    }
167
168    /// Sets the instrument and interval the draft is about, normally those of the chart.
169    pub fn set_context(&mut self, symbol: impl Into<String>, interval: impl Into<String>) {
170        self.draft.symbol = symbol.into();
171        self.draft.interval = interval.into();
172    }
173
174    /// The draft being composed.
175    pub fn draft(&self) -> &IdeaDraft {
176        &self.draft
177    }
178
179    /// Adds a page to the end of the feed, or replaces the feed when `replace` is set, as for the
180    /// first page or a refresh.
181    pub fn add_page(&mut self, page: IdeaPage, replace: bool) {
182        if replace {
183            self.ideas.clear();
184        }
185        self.ideas.extend(page.items);
186        self.next = page.next;
187    }
188
189    /// Records that the host published the draft as `id`, which clears the draft.
190    pub fn publish_succeeded(&mut self, id: impl Into<String>, link: Option<String>) {
191        self.draft.title.clear();
192        self.draft.body.clear();
193        self.publish = Publish::Done {
194            id: id.into(),
195            link,
196        };
197    }
198
199    /// Records that publishing failed. The draft stays for another attempt.
200    pub fn publish_failed(&mut self, reason: impl Into<String>) {
201        self.publish = Publish::Failed(reason.into());
202    }
203
204    /// Shows the panel in `ui` and returns what the user asked of the host.
205    pub fn show(&mut self, ui: &mut Ui) -> IdeasAction {
206        let mut action = IdeasAction::None;
207        PanelHeader::new("Ideas").show_plain(ui);
208        if !self.can_publish && !self.can_browse {
209            EmptyState::new("Ideas are not available")
210                .fill_height()
211                .show(ui);
212            return action;
213        }
214        ScrollArea::vertical()
215            .id_salt("ideas_scroll")
216            .auto_shrink([false; 2])
217            .show(ui, |ui| {
218                if self.can_publish {
219                    action = self.composer(ui);
220                }
221                if self.can_browse {
222                    let browse = self.feed(ui);
223                    if browse != IdeasAction::None {
224                        action = browse;
225                    }
226                }
227            });
228        action
229    }
230
231    fn composer(&mut self, ui: &mut Ui) -> IdeasAction {
232        let weak = ui.visuals().weak_text_color();
233        ui.add(
234            TextEdit::singleline(&mut self.draft.title)
235                .hint_text(l10n::text(ui.ctx(), "Title"))
236                .desired_width(f32::INFINITY),
237        );
238        ui.add(
239            TextEdit::multiline(&mut self.draft.body)
240                .hint_text(l10n::text(ui.ctx(), "What is your idea?"))
241                .desired_rows(4)
242                .desired_width(f32::INFINITY),
243        );
244        ui.label(
245            RichText::new(format!("{} {}", self.draft.symbol, self.draft.interval)).color(weak),
246        );
247        ui.checkbox(
248            &mut self.draft.attach_chart,
249            l10n::text(ui.ctx(), "Attach chart image"),
250        );
251        let pending = self.publish == Publish::Pending;
252        let ready = !self.draft.body.trim().is_empty() && !self.draft.symbol.is_empty();
253        let mut action = IdeasAction::None;
254        if Button::new("Publish")
255            .enabled(ready && !pending)
256            .show(ui)
257            .clicked()
258        {
259            self.publish = Publish::Pending;
260            action = IdeasAction::Publish(self.draft.clone());
261        }
262        match &self.publish {
263            Publish::Idle => {}
264            Publish::Pending => {
265                ui.label(RichText::new(l10n::text(ui.ctx(), "Publishing")).color(weak));
266            }
267            Publish::Done { id, link } => {
268                let text = link.as_deref().unwrap_or(id);
269                status_message(
270                    ui,
271                    MessageKind::Success,
272                    &format!("{} {text}", l10n::text(ui.ctx(), "Published")),
273                );
274            }
275            Publish::Failed(reason) => {
276                status_message(ui, MessageKind::Error, reason);
277            }
278        }
279        ui.separator();
280        action
281    }
282
283    fn feed(&mut self, ui: &mut Ui) -> IdeasAction {
284        let mut action = IdeasAction::None;
285        let weak = ui.visuals().weak_text_color();
286        ui.horizontal(|ui| {
287            for (kind, label) in [
288                (IdeaKind::Idea, "Ideas"),
289                (IdeaKind::ShortPost, "Short posts"),
290            ] {
291                ui.selectable_value(&mut self.filter, kind, l10n::text(ui.ctx(), label));
292            }
293        });
294        let shown: Vec<_> = self
295            .ideas
296            .iter()
297            .filter(|idea| idea.kind == self.filter)
298            .collect();
299        if shown.is_empty() {
300            EmptyState::new("Nothing to show yet").show(ui);
301        }
302        for idea in shown {
303            ui.group(|ui| {
304                ui.set_width(ui.available_width());
305                if !idea.title.is_empty() {
306                    ui.label(RichText::new(&idea.title).strong());
307                }
308                ui.label(&idea.body);
309                let mut meta = format!(
310                    "{} \u{b7} {} \u{b7} {} {}",
311                    idea.author,
312                    idea.time.format("%Y-%m-%d %H:%M"),
313                    idea.symbol,
314                    idea.interval
315                );
316                if idea.attachment.is_some() {
317                    meta += &format!(" \u{b7} {}", l10n::text(ui.ctx(), "Chart attached"));
318                }
319                ui.label(RichText::new(meta).color(weak));
320                ui.horizontal(|ui| {
321                    if ui.link(l10n::text(ui.ctx(), "Open")).clicked() {
322                        action = IdeasAction::Open(idea.id.clone());
323                    }
324                    if ui.link(l10n::text(ui.ctx(), "Show on chart")).clicked() {
325                        action = IdeasAction::OpenChart {
326                            symbol: idea.symbol.clone(),
327                            interval: idea.interval.clone(),
328                        };
329                    }
330                    if ui.link(l10n::text(ui.ctx(), "Place on chart")).clicked() {
331                        let mut content =
332                            PostContent::new(idea.author.clone(), idea.time, idea.body.clone());
333                        content.title = idea.title.clone();
334                        content.picture = idea.attachment.clone();
335                        action = IdeasAction::Place(PostLink::resolved(idea.id.clone(), content));
336                    }
337                });
338            });
339        }
340        if let Some(cursor) = &self.next
341            && Button::new("Load more").show(ui).clicked()
342        {
343            action = IdeasAction::LoadMore(cursor.clone());
344        }
345        action
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    /// Placement carries the entry's identifier and all its content, including the picture.
354    #[test]
355    fn place_on_chart_reports_the_entry_as_a_resolved_link() {
356        use chrono::TimeZone;
357        use egui::{Context, Event, Modifiers, PointerButton, RawInput, Rect, Shape};
358        let time = Utc.with_ymd_and_hms(2026, 10, 30, 9, 30, 0).unwrap();
359        let mut panel = IdeasPanel::new();
360        panel.set_browsing(true);
361        panel.add_page(
362            IdeaPage {
363                items: vec![Idea {
364                    id: "42".into(),
365                    kind: IdeaKind::Idea,
366                    title: "Breakout".into(),
367                    body: "Above the range".into(),
368                    author: "Ada".into(),
369                    time,
370                    symbol: "AAPL".into(),
371                    interval: "1D".into(),
372                    attachment: Some("https://example.invalid/42.png".into()),
373                }],
374                next: None,
375            },
376            true,
377        );
378        let ctx = Context::default();
379        let mut action = IdeasAction::None;
380        let mut step = |events| {
381            ctx.run_ui(
382                RawInput {
383                    screen_rect: Some(Rect::from_min_size(
384                        egui::Pos2::ZERO,
385                        egui::vec2(800.0, 600.0),
386                    )),
387                    events,
388                    ..Default::default()
389                },
390                |ui| {
391                    action = panel.show(ui);
392                },
393            )
394        };
395        let output = step(vec![]);
396        let pos = output
397            .shapes
398            .iter()
399            .find_map(|shape| match &shape.shape {
400                Shape::Text(text) if text.galley.text() == "Place on chart" => {
401                    Some(Rect::from_min_size(text.pos, text.galley.size()).center())
402                }
403                _ => None,
404            })
405            .unwrap();
406        output.drop_without_applying_deltas();
407        step(vec![Event::PointerMoved(pos)]).drop_without_applying_deltas();
408        for pressed in [true, false] {
409            step(vec![Event::PointerButton {
410                pos,
411                button: PointerButton::Primary,
412                pressed,
413                modifiers: Modifiers::NONE,
414            }])
415            .drop_without_applying_deltas();
416        }
417        let mut content = PostContent::new("Ada", time, "Above the range");
418        content.title = "Breakout".into();
419        content.picture = Some("https://example.invalid/42.png".into());
420        assert_eq!(
421            action,
422            IdeasAction::Place(PostLink::resolved("42", content))
423        );
424    }
425
426    #[test]
427    fn a_failed_publish_keeps_the_draft_and_a_successful_one_clears_it() {
428        let mut panel = IdeasPanel::new();
429        panel.set_context("AAPL", "1D");
430        panel.draft.title = "Breakout".into();
431        panel.draft.body = "Above the range".into();
432        panel.publish_failed("offline");
433        assert_eq!(panel.draft().title, "Breakout");
434        assert_eq!(panel.draft().body, "Above the range");
435        panel.publish_succeeded("42", Some("https://example.invalid/42".into()));
436        assert_eq!(
437            (panel.draft().title.as_str(), panel.draft().body.as_str()),
438            ("", "")
439        );
440        assert_eq!(panel.draft().symbol, "AAPL");
441    }
442
443    #[test]
444    fn controls_of_an_unavailable_service_are_not_shown() {
445        for (publishing, browsing) in [(true, false), (false, true), (false, false)] {
446            let mut panel = IdeasPanel::new();
447            panel.set_publishing(publishing);
448            panel.set_browsing(browsing);
449            panel.set_context("AAPL", "1D");
450            let ctx = egui::Context::default();
451            let output = ctx.run_ui(Default::default(), |ui| {
452                panel.show(ui);
453            });
454            let texts: Vec<String> = output
455                .shapes
456                .iter()
457                .filter_map(|shape| match &shape.shape {
458                    egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
459                    _ => None,
460                })
461                .collect();
462            output.drop_without_applying_deltas();
463            assert_eq!(texts.iter().any(|t| t == "Publish"), publishing);
464            assert_eq!(texts.iter().any(|t| t == "Short posts"), browsing);
465        }
466    }
467}