1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
22#[non_exhaustive]
23pub enum IdeaKind {
24 #[default]
26 Idea,
27 ShortPost,
29}
30
31#[derive(Clone, Debug, PartialEq)]
33pub struct Idea {
34 pub id: String,
36 pub kind: IdeaKind,
38 pub title: String,
40 pub body: String,
42 pub author: String,
44 pub time: DateTime<Utc>,
46 pub symbol: String,
48 pub interval: String,
50 pub attachment: Option<String>,
52}
53
54#[derive(Clone, Debug, PartialEq, Default)]
56pub struct IdeaPage {
57 pub items: Vec<Idea>,
59 pub next: Option<String>,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct IdeaDraft {
66 pub title: String,
68 pub body: String,
70 pub symbol: String,
72 pub interval: String,
74 pub attach_chart: bool,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum IdeasAction {
82 None,
84 Publish(IdeaDraft),
87 LoadMore(String),
89 Open(String),
91 OpenChart {
93 symbol: String,
95 interval: String,
97 },
98 Place(PostLink),
105}
106
107#[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
120pub 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 filter: IdeaKind,
130}
131
132impl Default for IdeasPanel {
133 fn default() -> Self {
134 Self::new()
135 }
136}
137
138impl IdeasPanel {
139 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 pub fn set_publishing(&mut self, available: bool) {
160 self.can_publish = available;
161 }
162
163 pub fn set_browsing(&mut self, available: bool) {
165 self.can_browse = available;
166 }
167
168 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 pub fn draft(&self) -> &IdeaDraft {
176 &self.draft
177 }
178
179 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 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 pub fn publish_failed(&mut self, reason: impl Into<String>) {
201 self.publish = Publish::Failed(reason.into());
202 }
203
204 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 #[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}