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(¬e, 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, ¬e, 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 }