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(¬e, 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, ¬e) 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, ¬e); 402 } 403 }); 404 405 ui::hline(ui); 406 } 407 408 1 409 }); 410 411 action 412 } 413 }