notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

timeline.rs (12718B)


      1 use std::f32::consts::PI;
      2 
      3 use crate::actionbar::NoteAction;
      4 use crate::timeline::TimelineTab;
      5 use crate::{
      6     timeline::{TimelineCache, TimelineKind, ViewFilter},
      7     ui,
      8     ui::note::NoteOptions,
      9 };
     10 use egui::containers::scroll_area::ScrollBarVisibility;
     11 use egui::{vec2, Direction, Layout, Pos2, Stroke};
     12 use egui_tabs::TabColor;
     13 use nostrdb::{Ndb, Transaction};
     14 use notedeck::note::root_note_id_from_selected_id;
     15 use notedeck::{ImageCache, MuteFun, NoteCache};
     16 use tracing::{error, warn};
     17 
     18 use super::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE};
     19 
     20 pub struct TimelineView<'a> {
     21     timeline_id: &'a TimelineKind,
     22     timeline_cache: &'a mut TimelineCache,
     23     ndb: &'a Ndb,
     24     note_cache: &'a mut NoteCache,
     25     img_cache: &'a mut ImageCache,
     26     note_options: NoteOptions,
     27     reverse: bool,
     28     is_muted: &'a MuteFun,
     29 }
     30 
     31 impl<'a> TimelineView<'a> {
     32     pub fn new(
     33         timeline_id: &'a TimelineKind,
     34         timeline_cache: &'a mut TimelineCache,
     35         ndb: &'a Ndb,
     36         note_cache: &'a mut NoteCache,
     37         img_cache: &'a mut ImageCache,
     38         note_options: NoteOptions,
     39         is_muted: &'a MuteFun,
     40     ) -> TimelineView<'a> {
     41         let reverse = false;
     42         TimelineView {
     43             ndb,
     44             timeline_id,
     45             timeline_cache,
     46             note_cache,
     47             img_cache,
     48             reverse,
     49             note_options,
     50             is_muted,
     51         }
     52     }
     53 
     54     pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
     55         timeline_ui(
     56             ui,
     57             self.ndb,
     58             self.timeline_id,
     59             self.timeline_cache,
     60             self.note_cache,
     61             self.img_cache,
     62             self.reverse,
     63             self.note_options,
     64             self.is_muted,
     65         )
     66     }
     67 
     68     pub fn reversed(mut self) -> Self {
     69         self.reverse = true;
     70         self
     71     }
     72 }
     73 
     74 #[allow(clippy::too_many_arguments)]
     75 fn timeline_ui(
     76     ui: &mut egui::Ui,
     77     ndb: &Ndb,
     78     timeline_id: &TimelineKind,
     79     timeline_cache: &mut TimelineCache,
     80     note_cache: &mut NoteCache,
     81     img_cache: &mut ImageCache,
     82     reversed: bool,
     83     note_options: NoteOptions,
     84     is_muted: &MuteFun,
     85 ) -> Option<NoteAction> {
     86     //padding(4.0, ui, |ui| ui.heading("Notifications"));
     87     /*
     88     let font_id = egui::TextStyle::Body.resolve(ui.style());
     89     let row_height = ui.fonts(|f| f.row_height(&font_id)) + ui.spacing().item_spacing.y;
     90 
     91     */
     92 
     93     let scroll_id = {
     94         let timeline = if let Some(timeline) = timeline_cache.timelines.get_mut(timeline_id) {
     95             timeline
     96         } else {
     97             error!("tried to render timeline in column, but timeline was missing");
     98             // TODO (jb55): render error when timeline is missing?
     99             // this shouldn't happen...
    100             return None;
    101         };
    102 
    103         timeline.selected_view = tabs_ui(ui, timeline.selected_view, &timeline.views);
    104 
    105         // need this for some reason??
    106         ui.add_space(3.0);
    107 
    108         egui::Id::new(("tlscroll", timeline.view_id()))
    109     };
    110 
    111     let show_top_button_id = ui.id().with((scroll_id, "at_top"));
    112 
    113     let show_top_button = ui
    114         .ctx()
    115         .data(|d| d.get_temp::<bool>(show_top_button_id))
    116         .unwrap_or(false);
    117 
    118     let goto_top_resp = if show_top_button {
    119         let top_button_pos = ui.available_rect_before_wrap().right_top() - vec2(48.0, -24.0);
    120         egui::Area::new(ui.id().with("foreground_area"))
    121             .order(egui::Order::Foreground)
    122             .fixed_pos(top_button_pos)
    123             .show(ui.ctx(), |ui| Some(ui.add(goto_top_button(top_button_pos))))
    124             .inner
    125     } else {
    126         None
    127     };
    128 
    129     let mut scroll_area = egui::ScrollArea::vertical()
    130         .id_salt(scroll_id)
    131         .animated(false)
    132         .auto_shrink([false, false])
    133         .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible);
    134 
    135     if let Some(goto_top_resp) = goto_top_resp {
    136         if goto_top_resp.clicked() {
    137             scroll_area = scroll_area.vertical_scroll_offset(0.0);
    138         } else if goto_top_resp.hovered() {
    139             ui::show_pointer(ui);
    140         }
    141     }
    142 
    143     let scroll_output = scroll_area.show(ui, |ui| {
    144         let timeline = if let Some(timeline) = timeline_cache.timelines.get(timeline_id) {
    145             timeline
    146         } else {
    147             error!("tried to render timeline in column, but timeline was missing");
    148             // TODO (jb55): render error when timeline is missing?
    149             // this shouldn't happen...
    150             return None;
    151         };
    152 
    153         let txn = Transaction::new(ndb).expect("failed to create txn");
    154         TimelineTabView::new(
    155             timeline.current_view(),
    156             reversed,
    157             note_options,
    158             &txn,
    159             ndb,
    160             note_cache,
    161             img_cache,
    162             is_muted,
    163         )
    164         .show(ui)
    165     });
    166 
    167     let at_top_after_scroll = scroll_output.state.offset.y == 0.0;
    168     let cur_show_top_button = ui.ctx().data(|d| d.get_temp::<bool>(show_top_button_id));
    169 
    170     if at_top_after_scroll {
    171         if cur_show_top_button != Some(false) {
    172             ui.ctx()
    173                 .data_mut(|d| d.insert_temp(show_top_button_id, false));
    174         }
    175     } else if cur_show_top_button == Some(false) {
    176         ui.ctx()
    177             .data_mut(|d| d.insert_temp(show_top_button_id, true));
    178     }
    179 
    180     scroll_output.inner
    181 }
    182 
    183 fn goto_top_button(center: Pos2) -> impl egui::Widget {
    184     move |ui: &mut egui::Ui| -> egui::Response {
    185         let radius = 12.0;
    186         let max_size = vec2(
    187             ICON_EXPANSION_MULTIPLE * 2.0 * radius,
    188             ICON_EXPANSION_MULTIPLE * 2.0 * radius,
    189         );
    190         let helper = AnimationHelper::new_from_rect(ui, "goto_top", {
    191             let painter = ui.painter();
    192             let center = painter.round_pos_to_pixel_center(center);
    193             egui::Rect::from_center_size(center, max_size)
    194         });
    195 
    196         let painter = ui.painter();
    197         painter.circle_filled(center, helper.scale_1d_pos(radius), crate::colors::PINK);
    198 
    199         let create_pt = |angle: f32| {
    200             let side = radius / 2.0;
    201             let x = side * angle.cos();
    202             let mut y = side * angle.sin();
    203 
    204             let height = (side * (3.0_f32).sqrt()) / 2.0;
    205             y += height / 2.0;
    206             Pos2 { x, y }
    207         };
    208 
    209         let left_pt =
    210             painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI)));
    211         let center_pt =
    212             painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI / 2.0)));
    213         let right_pt =
    214             painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(0.0)));
    215 
    216         let line_width = helper.scale_1d_pos(4.0);
    217         let line_color = ui.visuals().text_color();
    218         painter.line_segment([left_pt, center_pt], Stroke::new(line_width, line_color));
    219         painter.line_segment([center_pt, right_pt], Stroke::new(line_width, line_color));
    220 
    221         let end_radius = (line_width - 1.0) / 2.0;
    222         painter.circle_filled(left_pt, end_radius, line_color);
    223         painter.circle_filled(center_pt, end_radius, line_color);
    224         painter.circle_filled(right_pt, end_radius, line_color);
    225 
    226         helper.take_animation_response()
    227     }
    228 }
    229 
    230 pub fn tabs_ui(ui: &mut egui::Ui, selected: usize, views: &[TimelineTab]) -> usize {
    231     ui.spacing_mut().item_spacing.y = 0.0;
    232 
    233     let tab_res = egui_tabs::Tabs::new(views.len() as i32)
    234         .selected(selected as i32)
    235         .hover_bg(TabColor::none())
    236         .selected_fg(TabColor::none())
    237         .selected_bg(TabColor::none())
    238         .hover_bg(TabColor::none())
    239         //.hover_bg(TabColor::custom(egui::Color32::RED))
    240         .height(32.0)
    241         .layout(Layout::centered_and_justified(Direction::TopDown))
    242         .show(ui, |ui, state| {
    243             ui.spacing_mut().item_spacing.y = 0.0;
    244 
    245             let ind = state.index();
    246 
    247             let txt = match views[ind as usize].filter {
    248                 ViewFilter::Notes => "Notes",
    249                 ViewFilter::NotesAndReplies => "Notes & Replies",
    250             };
    251 
    252             let res = ui.add(egui::Label::new(txt).selectable(false));
    253 
    254             // underline
    255             if state.is_selected() {
    256                 let rect = res.rect;
    257                 let underline =
    258                     shrink_range_to_width(rect.x_range(), get_label_width(ui, txt) * 1.15);
    259                 let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5;
    260                 return (underline, underline_y);
    261             }
    262 
    263             (egui::Rangef::new(0.0, 0.0), 0.0)
    264         });
    265 
    266     //ui.add_space(0.5);
    267     ui::hline(ui);
    268 
    269     let sel = tab_res.selected().unwrap_or_default();
    270 
    271     let (underline, underline_y) = tab_res.inner()[sel as usize].inner;
    272     let underline_width = underline.span();
    273 
    274     let tab_anim_id = ui.id().with("tab_anim");
    275     let tab_anim_size = tab_anim_id.with("size");
    276 
    277     let stroke = egui::Stroke {
    278         color: ui.visuals().hyperlink_color,
    279         width: 2.0,
    280     };
    281 
    282     let speed = 0.1f32;
    283 
    284     // animate underline position
    285     let x = ui
    286         .ctx()
    287         .animate_value_with_time(tab_anim_id, underline.min, speed);
    288 
    289     // animate underline width
    290     let w = ui
    291         .ctx()
    292         .animate_value_with_time(tab_anim_size, underline_width, speed);
    293 
    294     let underline = egui::Rangef::new(x, x + w);
    295 
    296     ui.painter().hline(underline, underline_y, stroke);
    297 
    298     sel as usize
    299 }
    300 
    301 fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 {
    302     let font_id = egui::FontId::default();
    303     let galley = ui.fonts(|r| r.layout_no_wrap(text.to_string(), font_id, egui::Color32::WHITE));
    304     galley.rect.width()
    305 }
    306 
    307 fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef {
    308     let midpoint = (range.min + range.max) / 2.0;
    309     let half_width = width / 2.0;
    310 
    311     let min = midpoint - half_width;
    312     let max = midpoint + half_width;
    313 
    314     egui::Rangef::new(min, max)
    315 }
    316 
    317 pub struct TimelineTabView<'a> {
    318     tab: &'a TimelineTab,
    319     reversed: bool,
    320     note_options: NoteOptions,
    321     txn: &'a Transaction,
    322     ndb: &'a Ndb,
    323     note_cache: &'a mut NoteCache,
    324     img_cache: &'a mut ImageCache,
    325     is_muted: &'a MuteFun,
    326 }
    327 
    328 impl<'a> TimelineTabView<'a> {
    329     #[allow(clippy::too_many_arguments)]
    330     pub fn new(
    331         tab: &'a TimelineTab,
    332         reversed: bool,
    333         note_options: NoteOptions,
    334         txn: &'a Transaction,
    335         ndb: &'a Ndb,
    336         note_cache: &'a mut NoteCache,
    337         img_cache: &'a mut ImageCache,
    338         is_muted: &'a MuteFun,
    339     ) -> Self {
    340         Self {
    341             tab,
    342             reversed,
    343             txn,
    344             note_options,
    345             ndb,
    346             note_cache,
    347             img_cache,
    348             is_muted,
    349         }
    350     }
    351 
    352     pub fn show(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> {
    353         let mut action: Option<NoteAction> = None;
    354         let len = self.tab.notes.len();
    355 
    356         let is_muted = self.is_muted;
    357         self.tab
    358             .list
    359             .clone()
    360             .borrow_mut()
    361             .ui_custom_layout(ui, len, |ui, start_index| {
    362                 ui.spacing_mut().item_spacing.y = 0.0;
    363                 ui.spacing_mut().item_spacing.x = 4.0;
    364 
    365                 let ind = if self.reversed {
    366                     len - start_index - 1
    367                 } else {
    368                     start_index
    369                 };
    370 
    371                 let note_key = self.tab.notes[ind].key;
    372 
    373                 let note = if let Ok(note) = self.ndb.get_note_by_key(self.txn, note_key) {
    374                     note
    375                 } else {
    376                     warn!("failed to query note {:?}", note_key);
    377                     return 0;
    378                 };
    379 
    380                 // should we mute the thread? we might not have it!
    381                 let muted = if let Ok(root_id) =
    382                     root_note_id_from_selected_id(self.ndb, self.note_cache, self.txn, note.id())
    383                 {
    384                     is_muted(&note, root_id.bytes())
    385                 } else {
    386                     false
    387                 };
    388 
    389                 if !muted {
    390                     ui::padding(8.0, ui, |ui| {
    391                         let resp =
    392                             ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, &note)
    393                                 .note_options(self.note_options)
    394                                 .show(ui);
    395 
    396                         if let Some(note_action) = resp.action {
    397                             action = Some(note_action)
    398                         }
    399 
    400                         if let Some(context) = resp.context_selection {
    401                             context.process(ui, &note);
    402                         }
    403                     });
    404 
    405                     ui::hline(ui);
    406                 }
    407 
    408                 1
    409             });
    410 
    411         action
    412     }
    413 }