notedeck

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

timeline.rs (14313B)


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