notedeck

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

timeline.rs (12591B)


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