notedeck

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

timeline.rs (14584B)


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