notedeck

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

header.rs (20666B)


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