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