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(¬e, 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, ¬e, 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, ¬e); 394 } 395 }); 396 397 ui::hline(ui); 398 } 399 400 1 401 }); 402 403 action 404 } 405 }