notedeck

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

header.rs (20811B)


      1 use crate::colors;
      2 use crate::column::ColumnsAction;
      3 use crate::nav::RenderNavAction;
      4 use crate::nav::SwitchingAction;
      5 use crate::{
      6     column::Columns,
      7     route::Route,
      8     timeline::{ColumnTitle, TimelineId, TimelineKind, TimelineRoute},
      9     ui::{
     10         self,
     11         anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
     12     },
     13 };
     14 
     15 use egui::Margin;
     16 use egui::{RichText, Stroke, UiBuilder};
     17 use enostr::Pubkey;
     18 use nostrdb::{Ndb, Transaction};
     19 use notedeck::{ImageCache, NotedeckTextStyle};
     20 
     21 pub struct NavTitle<'a> {
     22     ndb: &'a Ndb,
     23     img_cache: &'a mut ImageCache,
     24     columns: &'a Columns,
     25     deck_author: Option<&'a Pubkey>,
     26     routes: &'a [Route],
     27     col_id: usize,
     28 }
     29 
     30 impl<'a> NavTitle<'a> {
     31     pub fn new(
     32         ndb: &'a Ndb,
     33         img_cache: &'a mut ImageCache,
     34         columns: &'a Columns,
     35         deck_author: Option<&'a Pubkey>,
     36         routes: &'a [Route],
     37         col_id: usize,
     38     ) -> Self {
     39         NavTitle {
     40             ndb,
     41             img_cache,
     42             columns,
     43             deck_author,
     44             routes,
     45             col_id,
     46         }
     47     }
     48 
     49     pub fn show(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> {
     50         ui::padding(8.0, ui, |ui| {
     51             let mut rect = ui.available_rect_before_wrap();
     52             rect.set_height(48.0);
     53 
     54             let mut child_ui = ui.new_child(
     55                 UiBuilder::new()
     56                     .max_rect(rect)
     57                     .layout(egui::Layout::left_to_right(egui::Align::Center)),
     58             );
     59 
     60             let r = self.title_bar(&mut child_ui);
     61 
     62             ui.advance_cursor_after_rect(rect);
     63 
     64             r
     65         })
     66         .inner
     67     }
     68 
     69     fn title_bar(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> {
     70         let item_spacing = 8.0;
     71         ui.spacing_mut().item_spacing.x = item_spacing;
     72 
     73         let chev_x = 8.0;
     74         let back_button_resp =
     75             prev(self.routes).map(|r| self.back_button(ui, r, egui::Vec2::new(chev_x, 15.0)));
     76 
     77         if let Some(back_resp) = &back_button_resp {
     78             if back_resp.hovered() || back_resp.clicked() {
     79                 ui::show_pointer(ui);
     80             }
     81         } else {
     82             // add some space where chevron would have been. this makes the ui
     83             // less bumpy when navigating
     84             ui.add_space(chev_x + item_spacing);
     85         }
     86 
     87         let title_resp = self.title(ui, self.routes.last().unwrap(), back_button_resp.is_some());
     88 
     89         if let Some(resp) = title_resp {
     90             match resp {
     91                 TitleResponse::RemoveColumn => Some(RenderNavAction::RemoveColumn),
     92                 TitleResponse::MoveColumn(to_index) => {
     93                     let from = self.col_id;
     94                     Some(RenderNavAction::SwitchingAction(SwitchingAction::Columns(
     95                         ColumnsAction::Switch(from, to_index),
     96                     )))
     97                 }
     98             }
     99         } else if back_button_resp.is_some_and(|r| r.clicked()) {
    100             Some(RenderNavAction::Back)
    101         } else {
    102             None
    103         }
    104     }
    105 
    106     fn back_button(
    107         &mut self,
    108         ui: &mut egui::Ui,
    109         prev: &Route,
    110         chev_size: egui::Vec2,
    111     ) -> egui::Response {
    112         //let color = ui.visuals().hyperlink_color;
    113         let color = ui.style().visuals.noninteractive().fg_stroke.color;
    114 
    115         //let spacing_prev = ui.spacing().item_spacing.x;
    116         //ui.spacing_mut().item_spacing.x = 0.0;
    117 
    118         let chev_resp = chevron(ui, 2.0, chev_size, Stroke::new(2.0, color));
    119 
    120         //ui.spacing_mut().item_spacing.x = spacing_prev;
    121 
    122         // NOTE(jb55): include graphic in back label as well because why
    123         // not it looks cool
    124         self.title_pfp(ui, prev, 32.0);
    125 
    126         let column_title = prev.title(self.columns);
    127 
    128         let back_resp = match &column_title {
    129             ColumnTitle::Simple(title) => ui.add(Self::back_label(title, color)),
    130 
    131             ColumnTitle::NeedsDb(need_db) => {
    132                 let txn = Transaction::new(self.ndb).unwrap();
    133                 let title = need_db.title(&txn, self.ndb, self.deck_author);
    134                 ui.add(Self::back_label(title, color))
    135             }
    136         };
    137 
    138         back_resp.union(chev_resp)
    139     }
    140 
    141     fn back_label(title: &str, color: egui::Color32) -> egui::Label {
    142         egui::Label::new(
    143             RichText::new(title.to_string())
    144                 .color(color)
    145                 .text_style(NotedeckTextStyle::Body.text_style()),
    146         )
    147         .selectable(false)
    148         .sense(egui::Sense::click())
    149     }
    150 
    151     fn delete_column_button(&self, ui: &mut egui::Ui, icon_width: f32) -> egui::Response {
    152         let img_size = 16.0;
    153         let max_size = icon_width * ICON_EXPANSION_MULTIPLE;
    154 
    155         let img_data = if ui.visuals().dark_mode {
    156             egui::include_image!("../../../../../assets/icons/column_delete_icon_4x.png")
    157         } else {
    158             egui::include_image!("../../../../../assets/icons/column_delete_icon_light_4x.png")
    159         };
    160         let img = egui::Image::new(img_data).max_width(img_size);
    161 
    162         let helper =
    163             AnimationHelper::new(ui, "delete-column-button", egui::vec2(max_size, max_size));
    164 
    165         let cur_img_size = helper.scale_1d_pos_min_max(0.0, img_size);
    166 
    167         let animation_rect = helper.get_animation_rect();
    168         let animation_resp = helper.take_animation_response();
    169 
    170         img.paint_at(ui, animation_rect.shrink((max_size - cur_img_size) / 2.0));
    171 
    172         animation_resp
    173     }
    174 
    175     fn delete_button_section(&self, ui: &mut egui::Ui) -> bool {
    176         let id = ui.id().with("title");
    177 
    178         let delete_button_resp = self.delete_column_button(ui, 32.0);
    179         if delete_button_resp.clicked() {
    180             ui.data_mut(|d| d.insert_temp(id, true));
    181         }
    182 
    183         if ui.data_mut(|d| *d.get_temp_mut_or_default(id)) {
    184             let mut confirm_pressed = false;
    185             delete_button_resp.show_tooltip_ui(|ui| {
    186                 let confirm_resp = ui.button("Confirm");
    187                 if confirm_resp.clicked() {
    188                     confirm_pressed = true;
    189                 }
    190 
    191                 if confirm_resp.clicked() || ui.button("Cancel").clicked() {
    192                     ui.data_mut(|d| d.insert_temp(id, false));
    193                 }
    194             });
    195             if !confirm_pressed && delete_button_resp.clicked_elsewhere() {
    196                 ui.data_mut(|d| d.insert_temp(id, false));
    197             }
    198             confirm_pressed
    199         } else {
    200             false
    201         }
    202     }
    203 
    204     // returns the column index to switch to, if any
    205     fn move_button_section(&mut self, ui: &mut egui::Ui) -> Option<usize> {
    206         let cur_id = ui.id().with("move");
    207         let mut move_resp = ui.add(grab_button());
    208 
    209         // showing the hover text while showing the move tooltip causes some weird visuals
    210         if ui.data(|d| d.get_temp::<bool>(cur_id).is_none()) {
    211             move_resp = move_resp.on_hover_text("Moves this column to another positon");
    212         }
    213 
    214         if move_resp.clicked() {
    215             ui.data_mut(|d| {
    216                 if let Some(val) = d.get_temp::<bool>(cur_id) {
    217                     if val {
    218                         d.remove_temp::<bool>(cur_id);
    219                     } else {
    220                         d.insert_temp(cur_id, true);
    221                     }
    222                 } else {
    223                     d.insert_temp(cur_id, true);
    224                 }
    225             });
    226         } else if move_resp.hovered() {
    227             ui::show_pointer(ui);
    228         }
    229 
    230         ui.data(|d| d.get_temp(cur_id)).and_then(|val| {
    231             if val {
    232                 let resp = self.add_move_tooltip(cur_id, &move_resp);
    233                 if move_resp.clicked_elsewhere() || resp.is_some() {
    234                     ui.data_mut(|d| d.remove_temp::<bool>(cur_id));
    235                 }
    236                 resp
    237             } else {
    238                 None
    239             }
    240         })
    241     }
    242 
    243     fn move_tooltip_col_presentation(&mut self, ui: &mut egui::Ui, col: usize) -> egui::Response {
    244         ui.horizontal(|ui| {
    245             self.title_presentation(ui, self.columns.column(col).router().top(), 32.0);
    246         })
    247         .response
    248     }
    249 
    250     fn add_move_tooltip(&mut self, id: egui::Id, move_resp: &egui::Response) -> Option<usize> {
    251         let mut inner_resp = None;
    252         move_resp.show_tooltip_ui(|ui| {
    253             // dnd frame color workaround
    254             ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::default();
    255             let x_range = ui.available_rect_before_wrap().x_range();
    256             let is_dragging = egui::DragAndDrop::payload::<usize>(ui.ctx()).is_some(); // must be outside ui.dnd_drop_zone to capture properly
    257             let (_, _) = ui.dnd_drop_zone::<usize, ()>(
    258                 egui::Frame::none().inner_margin(Margin::same(8.0)),
    259                 |ui| {
    260                     let distances: Vec<(egui::Response, f32)> =
    261                         self.collect_column_distances(ui, id);
    262 
    263                     if let Some((closest_index, closest_resp, distance)) =
    264                         self.find_closest_column(&distances)
    265                     {
    266                         if is_dragging && closest_index != self.col_id {
    267                             if self.should_draw_hint(closest_index, distance) {
    268                                 ui.painter().hline(
    269                                     x_range,
    270                                     self.calculate_hint_y(
    271                                         &distances,
    272                                         closest_resp,
    273                                         closest_index,
    274                                         distance,
    275                                     ),
    276                                     egui::Stroke::new(1.0, ui.visuals().text_color()),
    277                                 );
    278                             }
    279 
    280                             if ui.input(|i| i.pointer.any_released()) {
    281                                 inner_resp =
    282                                     Some(self.calculate_new_index(closest_index, distance));
    283                             }
    284                         }
    285                     }
    286                 },
    287             );
    288         });
    289         inner_resp
    290     }
    291 
    292     fn collect_column_distances(
    293         &mut self,
    294         ui: &mut egui::Ui,
    295         id: egui::Id,
    296     ) -> Vec<(egui::Response, f32)> {
    297         let y_margin = 4.0;
    298         let item_frame = egui::Frame::none()
    299             .rounding(egui::Rounding::same(8.0))
    300             .inner_margin(Margin::symmetric(8.0, y_margin));
    301 
    302         (0..self.columns.num_columns())
    303             .filter_map(|col| {
    304                 let item_id = id.with(col);
    305                 let col_resp = if col == self.col_id {
    306                     ui.dnd_drag_source(item_id, col, |ui| {
    307                         item_frame
    308                             .stroke(egui::Stroke::new(2.0, colors::PINK))
    309                             .fill(ui.visuals().widgets.noninteractive.bg_stroke.color)
    310                             .show(ui, |ui| self.move_tooltip_col_presentation(ui, col));
    311                     })
    312                     .response
    313                 } else {
    314                     item_frame
    315                         .show(ui, |ui| {
    316                             self.move_tooltip_col_presentation(ui, col)
    317                                 .on_hover_cursor(egui::CursorIcon::NotAllowed)
    318                         })
    319                         .response
    320                 };
    321 
    322                 ui.input(|i| i.pointer.interact_pos()).map(|pointer| {
    323                     let distance = pointer.y - col_resp.rect.center().y;
    324                     (col_resp, distance)
    325                 })
    326             })
    327             .collect()
    328     }
    329 
    330     fn find_closest_column(
    331         &'a self,
    332         distances: &'a [(egui::Response, f32)],
    333     ) -> Option<(usize, &'a egui::Response, f32)> {
    334         distances
    335             .iter()
    336             .enumerate()
    337             .min_by(|(_, (_, dist1)), (_, (_, dist2))| {
    338                 dist1.abs().partial_cmp(&dist2.abs()).unwrap()
    339             })
    340             .filter(|(index, (_, distance))| {
    341                 (index + 1 != self.col_id && *distance > 0.0)
    342                     || (index.saturating_sub(1) != self.col_id && *distance < 0.0)
    343             })
    344             .map(|(index, (resp, dist))| (index, resp, *dist))
    345     }
    346 
    347     fn should_draw_hint(&self, closest_index: usize, distance: f32) -> bool {
    348         let is_above = distance < 0.0;
    349         (is_above && closest_index.saturating_sub(1) != self.col_id)
    350             || (!is_above && closest_index + 1 != self.col_id)
    351     }
    352 
    353     fn calculate_new_index(&self, closest_index: usize, distance: f32) -> usize {
    354         let moving_up = self.col_id > closest_index;
    355         match (distance < 0.0, moving_up) {
    356             (true, true) | (false, false) => closest_index,
    357             (true, false) => closest_index.saturating_sub(1),
    358             (false, true) => closest_index + 1,
    359         }
    360     }
    361 
    362     fn calculate_hint_y(
    363         &self,
    364         distances: &[(egui::Response, f32)],
    365         closest_resp: &egui::Response,
    366         closest_index: usize,
    367         distance: f32,
    368     ) -> f32 {
    369         let y_margin = 4.0;
    370 
    371         let offset = if distance < 0.0 {
    372             distances
    373                 .get(closest_index.wrapping_sub(1))
    374                 .map(|(above_resp, _)| (closest_resp.rect.top() - above_resp.rect.bottom()) / 2.0)
    375                 .unwrap_or(y_margin)
    376         } else {
    377             distances
    378                 .get(closest_index + 1)
    379                 .map(|(below_resp, _)| (below_resp.rect.top() - closest_resp.rect.bottom()) / 2.0)
    380                 .unwrap_or(y_margin)
    381         };
    382 
    383         if distance < 0.0 {
    384             closest_resp.rect.top() - offset
    385         } else {
    386             closest_resp.rect.bottom() + offset
    387         }
    388     }
    389 
    390     fn pubkey_pfp<'txn, 'me>(
    391         &'me mut self,
    392         txn: &'txn Transaction,
    393         pubkey: &[u8; 32],
    394         pfp_size: f32,
    395     ) -> Option<ui::ProfilePic<'me, 'txn>> {
    396         self.ndb
    397             .get_profile_by_pubkey(txn, pubkey)
    398             .as_ref()
    399             .ok()
    400             .and_then(move |p| {
    401                 Some(ui::ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size))
    402             })
    403     }
    404 
    405     fn timeline_pfp(&mut self, ui: &mut egui::Ui, id: TimelineId, pfp_size: f32) {
    406         let txn = Transaction::new(self.ndb).unwrap();
    407 
    408         if let Some(pfp) = self
    409             .columns
    410             .find_timeline(id)
    411             .and_then(|tl| tl.kind.pubkey_source())
    412             .and_then(|pksrc| self.deck_author.map(|da| pksrc.to_pubkey(da)))
    413             .and_then(|pk| self.pubkey_pfp(&txn, pk.bytes(), pfp_size))
    414         {
    415             ui.add(pfp);
    416         } else {
    417             ui.add(
    418                 ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size),
    419             );
    420         }
    421     }
    422 
    423     fn title_pfp(&mut self, ui: &mut egui::Ui, top: &Route, pfp_size: f32) {
    424         match top {
    425             Route::Timeline(tlr) => match tlr {
    426                 TimelineRoute::Timeline(tlid) => {
    427                     let is_hashtag = self
    428                         .columns
    429                         .find_timeline(*tlid)
    430                         .is_some_and(|tl| matches!(tl.kind, TimelineKind::Hashtag(_)));
    431 
    432                     if is_hashtag {
    433                         ui.add(
    434                             egui::Image::new(egui::include_image!(
    435                                 "../../../../../assets/icons/hashtag_icon_4x.png"
    436                             ))
    437                             .fit_to_exact_size(egui::vec2(pfp_size, pfp_size)),
    438                         );
    439                     } else {
    440                         self.timeline_pfp(ui, *tlid, pfp_size);
    441                     }
    442                 }
    443 
    444                 TimelineRoute::Thread(_note_id) => {}
    445                 TimelineRoute::Reply(_note_id) => {}
    446                 TimelineRoute::Quote(_note_id) => {}
    447 
    448                 TimelineRoute::Profile(pubkey) => {
    449                     self.show_profile(ui, pubkey, pfp_size);
    450                 }
    451             },
    452 
    453             Route::Accounts(_as) => {}
    454             Route::ComposeNote => {}
    455             Route::AddColumn(_add_col_route) => {}
    456             Route::Support => {}
    457             Route::Relays => {}
    458             Route::NewDeck => {}
    459             Route::EditDeck(_) => {}
    460             Route::EditProfile(pubkey) => {
    461                 self.show_profile(ui, pubkey, pfp_size);
    462             }
    463         }
    464     }
    465 
    466     fn show_profile(&mut self, ui: &mut egui::Ui, pubkey: &Pubkey, pfp_size: f32) {
    467         let txn = Transaction::new(self.ndb).unwrap();
    468         if let Some(pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) {
    469             ui.add(pfp);
    470         } else {
    471             ui.add(
    472                 ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size),
    473             );
    474         };
    475     }
    476 
    477     fn title_label_value(title: &str) -> egui::Label {
    478         egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()))
    479             .selectable(false)
    480     }
    481 
    482     fn title_label(&self, ui: &mut egui::Ui, top: &Route) {
    483         let column_title = top.title(self.columns);
    484 
    485         match &column_title {
    486             ColumnTitle::Simple(title) => {
    487                 ui.add(Self::title_label_value(title));
    488             }
    489 
    490             ColumnTitle::NeedsDb(need_db) => {
    491                 let txn = Transaction::new(self.ndb).unwrap();
    492                 let title = need_db.title(&txn, self.ndb, self.deck_author);
    493                 ui.add(Self::title_label_value(title));
    494             }
    495         };
    496     }
    497 
    498     fn title(&mut self, ui: &mut egui::Ui, top: &Route, navigating: bool) -> Option<TitleResponse> {
    499         if !navigating {
    500             self.title_presentation(ui, top, 32.0);
    501         }
    502 
    503         ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
    504             if navigating {
    505                 self.title_presentation(ui, top, 32.0);
    506                 None
    507             } else {
    508                 let move_col = self.move_button_section(ui);
    509                 let remove_col = self.delete_button_section(ui);
    510                 if let Some(col) = move_col {
    511                     Some(TitleResponse::MoveColumn(col))
    512                 } else if remove_col {
    513                     Some(TitleResponse::RemoveColumn)
    514                 } else {
    515                     None
    516                 }
    517             }
    518         })
    519         .inner
    520     }
    521 
    522     fn title_presentation(&mut self, ui: &mut egui::Ui, top: &Route, pfp_size: f32) {
    523         self.title_pfp(ui, top, pfp_size);
    524         self.title_label(ui, top);
    525     }
    526 }
    527 
    528 enum TitleResponse {
    529     RemoveColumn,
    530     MoveColumn(usize),
    531 }
    532 
    533 fn prev<R>(xs: &[R]) -> Option<&R> {
    534     xs.get(xs.len().checked_sub(2)?)
    535 }
    536 
    537 fn chevron(
    538     ui: &mut egui::Ui,
    539     pad: f32,
    540     size: egui::Vec2,
    541     stroke: impl Into<Stroke>,
    542 ) -> egui::Response {
    543     let (r, painter) = ui.allocate_painter(size, egui::Sense::click());
    544 
    545     let min = r.rect.min;
    546     let max = r.rect.max;
    547 
    548     let apex = egui::Pos2::new(min.x + pad, min.y + size.y / 2.0);
    549     let top = egui::Pos2::new(max.x - pad, min.y + pad);
    550     let bottom = egui::Pos2::new(max.x - pad, max.y - pad);
    551 
    552     let stroke = stroke.into();
    553     painter.line_segment([apex, top], stroke);
    554     painter.line_segment([apex, bottom], stroke);
    555 
    556     r
    557 }
    558 
    559 fn grab_button() -> impl egui::Widget {
    560     |ui: &mut egui::Ui| -> egui::Response {
    561         let max_size = egui::vec2(20.0, 20.0);
    562         let helper = AnimationHelper::new(ui, "grab", max_size);
    563         let painter = ui.painter_at(helper.get_animation_rect());
    564         let min_circle_radius = 1.0;
    565         let cur_circle_radius = helper.scale_1d_pos(min_circle_radius);
    566         let horiz_spacing = 4.0;
    567         let vert_spacing = 10.0;
    568         let horiz_from_center = (horiz_spacing + min_circle_radius) / 2.0;
    569         let vert_from_center = (vert_spacing + min_circle_radius) / 2.0;
    570 
    571         let color = ui.style().visuals.noninteractive().fg_stroke.color;
    572 
    573         let middle_left = helper.scale_from_center(-horiz_from_center, 0.0);
    574         let middle_right = helper.scale_from_center(horiz_from_center, 0.0);
    575         let top_left = helper.scale_from_center(-horiz_from_center, -vert_from_center);
    576         let top_right = helper.scale_from_center(horiz_from_center, -vert_from_center);
    577         let bottom_left = helper.scale_from_center(-horiz_from_center, vert_from_center);
    578         let bottom_right = helper.scale_from_center(horiz_from_center, vert_from_center);
    579 
    580         painter.circle_filled(middle_left, cur_circle_radius, color);
    581         painter.circle_filled(middle_right, cur_circle_radius, color);
    582         painter.circle_filled(top_left, cur_circle_radius, color);
    583         painter.circle_filled(top_right, cur_circle_radius, color);
    584         painter.circle_filled(bottom_left, cur_circle_radius, color);
    585         painter.circle_filled(bottom_right, cur_circle_radius, color);
    586 
    587         helper.take_animation_response()
    588     }
    589 }