notedeck

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

header.rs (25484B)


      1 use crate::column::ColumnsAction;
      2 use crate::nav::RenderNavAction;
      3 use crate::nav::SwitchingAction;
      4 use crate::timeline::ThreadSelection;
      5 use crate::{
      6     column::Columns,
      7     route::Route,
      8     timeline::{ColumnTitle, TimelineKind},
      9     ui::{self},
     10 };
     11 
     12 use egui::UiBuilder;
     13 use egui::{Margin, Response, RichText, Sense, Stroke};
     14 use enostr::Pubkey;
     15 use nostrdb::{Ndb, Transaction};
     16 use notedeck::tr;
     17 use notedeck::{Images, Localization, MediaJobSender, NotedeckTextStyle};
     18 use notedeck_ui::app_images;
     19 use notedeck_ui::header::chevron;
     20 use notedeck_ui::{
     21     anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
     22     ProfilePic,
     23 };
     24 
     25 pub struct NavTitle<'a> {
     26     ndb: &'a Ndb,
     27     img_cache: &'a mut Images,
     28     columns: &'a Columns,
     29     routes: &'a [Route],
     30     col_id: usize,
     31     options: u32,
     32     i18n: &'a mut Localization,
     33     jobs: &'a MediaJobSender,
     34 }
     35 
     36 struct HeaderAnim {
     37     padding: f32,
     38     height: f32,
     39 }
     40 
     41 fn toolbar_visibility_amount(ui: &mut egui::Ui) -> f32 {
     42     let toolbar_visible_id = egui::Id::new("toolbar_visible");
     43     let toolbar_visible = ui
     44         .ctx()
     45         .data(|d| d.get_temp::<bool>(toolbar_visible_id))
     46         .unwrap_or(true);
     47     ui.ctx()
     48         .animate_bool_responsive(toolbar_visible_id.with("anim"), toolbar_visible)
     49 }
     50 
     51 fn header_anim(ui: &mut egui::Ui) -> Option<HeaderAnim> {
     52     let base_padding = 8.0;
     53     let base_height = 48.0;
     54 
     55     let navbar_anim = toolbar_visibility_amount(ui);
     56     let is_wide = !notedeck::ui::is_narrow(ui.ctx());
     57 
     58     if is_wide {
     59         // Wide mode: no animation, always show full header
     60         Some(HeaderAnim {
     61             padding: base_padding,
     62             height: base_height,
     63         })
     64     } else if navbar_anim < 0.01 {
     65         // Narrow mode with negligible visibility: don't render
     66         None
     67     } else {
     68         // Narrow mode: animate
     69         let height = base_height * navbar_anim;
     70         let padding = base_padding * navbar_anim;
     71 
     72         Some(HeaderAnim { padding, height })
     73     }
     74 }
     75 
     76 impl<'a> NavTitle<'a> {
     77     // options
     78     const SHOW_MOVE: u32 = 1 << 0;
     79     const SHOW_DELETE: u32 = 1 << 1;
     80 
     81     pub fn new(
     82         ndb: &'a Ndb,
     83         img_cache: &'a mut Images,
     84         columns: &'a Columns,
     85         routes: &'a [Route],
     86         col_id: usize,
     87         i18n: &'a mut Localization,
     88         jobs: &'a MediaJobSender,
     89     ) -> Self {
     90         let options = Self::SHOW_MOVE | Self::SHOW_DELETE;
     91         NavTitle {
     92             ndb,
     93             img_cache,
     94             columns,
     95             routes,
     96             col_id,
     97             options,
     98             i18n,
     99             jobs,
    100         }
    101     }
    102 
    103     pub fn show(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> {
    104         // On mobile, animate navbar visibility in sync with the toolbar
    105         // (toolbar_visible is set in render_damus_mobile based on scroll direction)
    106 
    107         let anim = header_anim(ui)?;
    108 
    109         notedeck_ui::padding(anim.padding, ui, |ui| {
    110             let mut rect = ui.available_rect_before_wrap();
    111             rect.set_height(anim.height);
    112 
    113             let mut child_ui = ui.new_child(
    114                 UiBuilder::new()
    115                     .max_rect(rect)
    116                     .layout(egui::Layout::left_to_right(egui::Align::Center)),
    117             );
    118 
    119             let interact_rect = child_ui.interact(rect, child_ui.id().with("drag"), Sense::drag());
    120             if interact_rect.drag_started_by(egui::PointerButton::Primary) {
    121                 child_ui
    122                     .ctx()
    123                     .send_viewport_cmd(egui::ViewportCommand::StartDrag);
    124             }
    125 
    126             let r = self.title_bar(&mut child_ui);
    127 
    128             ui.advance_cursor_after_rect(rect);
    129 
    130             r
    131         })
    132         .inner
    133     }
    134 
    135     fn title_bar(&mut self, ui: &mut egui::Ui) -> Option<RenderNavAction> {
    136         let item_spacing = 8.0;
    137         ui.spacing_mut().item_spacing.x = item_spacing;
    138 
    139         let chev_x = 8.0;
    140         let back_button_resp = prev(self.routes).map(|r| {
    141             self.back_button(ui, r, egui::Vec2::new(chev_x, 15.0))
    142                 .on_hover_cursor(egui::CursorIcon::PointingHand)
    143         });
    144 
    145         if back_button_resp.is_none() {
    146             // add some space where chevron would have been. this makes the ui
    147             // less bumpy when navigating
    148             ui.add_space(chev_x + item_spacing);
    149         }
    150 
    151         let title_resp = self.title(ui, self.routes.last().unwrap(), back_button_resp.is_some());
    152 
    153         if let Some(resp) = title_resp {
    154             tracing::debug!("got title response {resp:?}");
    155             match resp {
    156                 TitleResponse::RemoveColumn => Some(RenderNavAction::RemoveColumn),
    157                 TitleResponse::PfpClicked => Some(RenderNavAction::PfpClicked),
    158                 TitleResponse::MoveColumn(to_index) => {
    159                     let from = self.col_id;
    160                     Some(RenderNavAction::SwitchingAction(SwitchingAction::Columns(
    161                         ColumnsAction::Switch(from, to_index),
    162                     )))
    163                 }
    164             }
    165         } else if back_button_resp.is_some_and(|r| r.clicked()) {
    166             tracing::debug!("render nav action back");
    167             Some(RenderNavAction::Back)
    168         } else {
    169             None
    170         }
    171     }
    172 
    173     fn back_button(
    174         &mut self,
    175         ui: &mut egui::Ui,
    176         prev: &Route,
    177         chev_size: egui::Vec2,
    178     ) -> egui::Response {
    179         //let color = ui.visuals().hyperlink_color;
    180         let color = ui.style().visuals.noninteractive().fg_stroke.color;
    181 
    182         //let spacing_prev = ui.spacing().item_spacing.x;
    183         //ui.spacing_mut().item_spacing.x = 0.0;
    184 
    185         let chev_resp = chevron(ui, 2.0, chev_size, Stroke::new(2.0, color));
    186 
    187         //ui.spacing_mut().item_spacing.x = spacing_prev;
    188 
    189         // NOTE(jb55): include graphic in back label as well because why
    190         // not it looks cool
    191         let pfp_resp = self.title_pfp(ui, prev, 32.0);
    192         let column_title = prev.title(self.i18n);
    193 
    194         let back_resp = match &column_title {
    195             ColumnTitle::Simple(title) => ui.add(Self::back_label(title, color)),
    196 
    197             ColumnTitle::NeedsDb(need_db) => {
    198                 let txn = Transaction::new(self.ndb).unwrap();
    199                 let title = need_db.title(&txn, self.ndb);
    200                 ui.add(Self::back_label(title, color))
    201             }
    202         };
    203 
    204         if let Some(pfp_resp) = pfp_resp {
    205             back_resp.union(chev_resp).union(pfp_resp)
    206         } else {
    207             back_resp.union(chev_resp)
    208         }
    209     }
    210 
    211     fn back_label(title: &str, color: egui::Color32) -> egui::Label {
    212         egui::Label::new(
    213             RichText::new(title.to_string())
    214                 .color(color)
    215                 .text_style(NotedeckTextStyle::Body.text_style()),
    216         )
    217         .selectable(false)
    218         .sense(egui::Sense::click())
    219     }
    220 
    221     fn delete_column_button(&self, ui: &mut egui::Ui, icon_width: f32) -> egui::Response {
    222         let img_size = 16.0;
    223         let max_size = icon_width * ICON_EXPANSION_MULTIPLE;
    224 
    225         let img = (if ui.visuals().dark_mode {
    226             app_images::delete_dark_image()
    227         } else {
    228             app_images::delete_light_image()
    229         })
    230         .max_width(img_size);
    231 
    232         let helper =
    233             AnimationHelper::new(ui, "delete-column-button", egui::vec2(max_size, max_size));
    234 
    235         let cur_img_size = helper.scale_1d_pos_min_max(0.0, img_size);
    236 
    237         let animation_rect = helper.get_animation_rect();
    238         let animation_resp = helper.take_animation_response();
    239 
    240         img.paint_at(ui, animation_rect.shrink((max_size - cur_img_size) / 2.0));
    241 
    242         animation_resp
    243     }
    244 
    245     fn delete_button_section(&mut self, ui: &mut egui::Ui) -> bool {
    246         let id = ui.id().with("title");
    247 
    248         let delete_button_resp = self.delete_column_button(ui, 32.0);
    249         if delete_button_resp.clicked() {
    250             ui.data_mut(|d| d.insert_temp(id, true));
    251         }
    252 
    253         if ui.data_mut(|d| *d.get_temp_mut_or_default(id)) {
    254             let mut confirm_pressed = false;
    255             delete_button_resp.show_tooltip_ui(|ui| {
    256                 let confirm_resp = ui.button(tr!(
    257                     self.i18n,
    258                     "Confirm",
    259                     "Button label to confirm an action"
    260                 ));
    261                 if confirm_resp.clicked() {
    262                     confirm_pressed = true;
    263                 }
    264 
    265                 if confirm_resp.clicked()
    266                     || ui
    267                         .button(tr!(self.i18n, "Cancel", "Button label to cancel an action"))
    268                         .clicked()
    269                 {
    270                     ui.data_mut(|d| d.insert_temp(id, false));
    271                 }
    272             });
    273             if !confirm_pressed && delete_button_resp.clicked_elsewhere() {
    274                 ui.data_mut(|d| d.insert_temp(id, false));
    275             }
    276             confirm_pressed
    277         } else {
    278             delete_button_resp.on_hover_text(tr!(
    279                 self.i18n,
    280                 "Delete this column",
    281                 "Tooltip for deleting a column"
    282             ));
    283             false
    284         }
    285     }
    286 
    287     // returns the column index to switch to, if any
    288     fn move_button_section(&mut self, ui: &mut egui::Ui) -> Option<usize> {
    289         let cur_id = ui.id().with("move");
    290         let mut move_resp = ui
    291             .add(grab_button())
    292             .on_hover_cursor(egui::CursorIcon::PointingHand);
    293 
    294         // showing the hover text while showing the move tooltip causes some weird visuals
    295         if ui.data(|d| d.get_temp::<bool>(cur_id).is_none()) {
    296             move_resp = move_resp.on_hover_text(tr!(
    297                 self.i18n,
    298                 "Moves this column to another position",
    299                 "Tooltip for moving a column"
    300             ));
    301         }
    302 
    303         if move_resp.clicked() {
    304             ui.data_mut(|d| {
    305                 if let Some(val) = d.get_temp::<bool>(cur_id) {
    306                     if val {
    307                         d.remove_temp::<bool>(cur_id);
    308                     } else {
    309                         d.insert_temp(cur_id, true);
    310                     }
    311                 } else {
    312                     d.insert_temp(cur_id, true);
    313                 }
    314             });
    315         }
    316 
    317         ui.data(|d| d.get_temp(cur_id)).and_then(|val| {
    318             if val {
    319                 let resp = self.add_move_tooltip(cur_id, &move_resp);
    320                 if move_resp.clicked_elsewhere() || resp.is_some() {
    321                     ui.data_mut(|d| d.remove_temp::<bool>(cur_id));
    322                 }
    323                 resp
    324             } else {
    325                 None
    326             }
    327         })
    328     }
    329 
    330     fn move_tooltip_col_presentation(&mut self, ui: &mut egui::Ui, col: usize) -> egui::Response {
    331         ui.horizontal(|ui| {
    332             self.title_presentation(ui, self.columns.column(col).router().top(), 32.0);
    333         })
    334         .response
    335     }
    336 
    337     fn add_move_tooltip(&mut self, id: egui::Id, move_resp: &egui::Response) -> Option<usize> {
    338         let mut inner_resp = None;
    339         move_resp.show_tooltip_ui(|ui| {
    340             // dnd frame color workaround
    341             ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::default();
    342             let x_range = ui.available_rect_before_wrap().x_range();
    343             let is_dragging = egui::DragAndDrop::payload::<usize>(ui.ctx()).is_some(); // must be outside ui.dnd_drop_zone to capture properly
    344             let (_, _) = ui.dnd_drop_zone::<usize, ()>(
    345                 egui::Frame::new().inner_margin(Margin::same(8)),
    346                 |ui| {
    347                     let distances: Vec<(egui::Response, f32)> =
    348                         self.collect_column_distances(ui, id);
    349 
    350                     if let Some((closest_index, closest_resp, distance)) =
    351                         self.find_closest_column(&distances)
    352                     {
    353                         if is_dragging && closest_index != self.col_id {
    354                             if self.should_draw_hint(closest_index, distance) {
    355                                 ui.painter().hline(
    356                                     x_range,
    357                                     self.calculate_hint_y(
    358                                         &distances,
    359                                         closest_resp,
    360                                         closest_index,
    361                                         distance,
    362                                     ),
    363                                     egui::Stroke::new(1.0, ui.visuals().text_color()),
    364                                 );
    365                             }
    366 
    367                             if ui.input(|i| i.pointer.any_released()) {
    368                                 inner_resp =
    369                                     Some(self.calculate_new_index(closest_index, distance));
    370                             }
    371                         }
    372                     }
    373                 },
    374             );
    375         });
    376         inner_resp
    377     }
    378 
    379     fn collect_column_distances(
    380         &mut self,
    381         ui: &mut egui::Ui,
    382         id: egui::Id,
    383     ) -> Vec<(egui::Response, f32)> {
    384         let y_margin: i8 = 4;
    385         let item_frame = egui::Frame::new()
    386             .corner_radius(egui::CornerRadius::same(8))
    387             .inner_margin(Margin::symmetric(8, y_margin));
    388 
    389         (0..self.columns.num_columns())
    390             .filter_map(|col| {
    391                 let item_id = id.with(col);
    392                 let col_resp = if col == self.col_id {
    393                     ui.dnd_drag_source(item_id, col, |ui| {
    394                         item_frame
    395                             .stroke(egui::Stroke::new(2.0, notedeck_ui::colors::PINK))
    396                             .fill(ui.visuals().widgets.noninteractive.bg_stroke.color)
    397                             .show(ui, |ui| self.move_tooltip_col_presentation(ui, col));
    398                     })
    399                     .response
    400                 } else {
    401                     item_frame
    402                         .show(ui, |ui| {
    403                             self.move_tooltip_col_presentation(ui, col)
    404                                 .on_hover_cursor(egui::CursorIcon::NotAllowed)
    405                         })
    406                         .response
    407                 };
    408 
    409                 ui.input(|i| i.pointer.interact_pos()).map(|pointer| {
    410                     let distance = pointer.y - col_resp.rect.center().y;
    411                     (col_resp, distance)
    412                 })
    413             })
    414             .collect()
    415     }
    416 
    417     fn find_closest_column(
    418         &'a self,
    419         distances: &'a [(egui::Response, f32)],
    420     ) -> Option<(usize, &'a egui::Response, f32)> {
    421         distances
    422             .iter()
    423             .enumerate()
    424             .min_by(|(_, (_, dist1)), (_, (_, dist2))| {
    425                 dist1.abs().partial_cmp(&dist2.abs()).unwrap()
    426             })
    427             .filter(|(index, (_, distance))| {
    428                 (index + 1 != self.col_id && *distance > 0.0)
    429                     || (index.saturating_sub(1) != self.col_id && *distance < 0.0)
    430             })
    431             .map(|(index, (resp, dist))| (index, resp, *dist))
    432     }
    433 
    434     fn should_draw_hint(&self, closest_index: usize, distance: f32) -> bool {
    435         let is_above = distance < 0.0;
    436         (is_above && closest_index.saturating_sub(1) != self.col_id)
    437             || (!is_above && closest_index + 1 != self.col_id)
    438     }
    439 
    440     fn calculate_new_index(&self, closest_index: usize, distance: f32) -> usize {
    441         let moving_up = self.col_id > closest_index;
    442         match (distance < 0.0, moving_up) {
    443             (true, true) | (false, false) => closest_index,
    444             (true, false) => closest_index.saturating_sub(1),
    445             (false, true) => closest_index + 1,
    446         }
    447     }
    448 
    449     fn calculate_hint_y(
    450         &self,
    451         distances: &[(egui::Response, f32)],
    452         closest_resp: &egui::Response,
    453         closest_index: usize,
    454         distance: f32,
    455     ) -> f32 {
    456         let y_margin = 4.0;
    457 
    458         let offset = if distance < 0.0 {
    459             distances
    460                 .get(closest_index.wrapping_sub(1))
    461                 .map(|(above_resp, _)| (closest_resp.rect.top() - above_resp.rect.bottom()) / 2.0)
    462                 .unwrap_or(y_margin)
    463         } else {
    464             distances
    465                 .get(closest_index + 1)
    466                 .map(|(below_resp, _)| (below_resp.rect.top() - closest_resp.rect.bottom()) / 2.0)
    467                 .unwrap_or(y_margin)
    468         };
    469 
    470         if distance < 0.0 {
    471             closest_resp.rect.top() - offset
    472         } else {
    473             closest_resp.rect.bottom() + offset
    474         }
    475     }
    476 
    477     fn pubkey_pfp<'txn, 'me>(
    478         &'me mut self,
    479         txn: &'txn Transaction,
    480         pubkey: &[u8; 32],
    481         pfp_size: f32,
    482     ) -> Option<ProfilePic<'me, 'txn>> {
    483         self.ndb
    484             .get_profile_by_pubkey(txn, pubkey)
    485             .as_ref()
    486             .ok()
    487             .and_then(move |p| {
    488                 ProfilePic::from_profile(self.img_cache, self.jobs, p)
    489                     .map(|pfp| pfp.size(pfp_size).sense(Sense::click()))
    490             })
    491     }
    492 
    493     fn timeline_pfp(&mut self, ui: &mut egui::Ui, id: &TimelineKind, pfp_size: f32) -> Response {
    494         let txn = Transaction::new(self.ndb).unwrap();
    495 
    496         if let Some(mut pfp) = id
    497             .pubkey()
    498             .and_then(|pk| self.pubkey_pfp(&txn, pk.bytes(), pfp_size))
    499         {
    500             ui.add(&mut pfp)
    501         } else {
    502             ui.add(
    503                 &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url())
    504                     .size(pfp_size)
    505                     .sense(Sense::click()),
    506             )
    507         }
    508     }
    509 
    510     fn title_pfp(&mut self, ui: &mut egui::Ui, top: &Route, pfp_size: f32) -> Option<Response> {
    511         match top {
    512             Route::Timeline(kind) => match kind {
    513                 TimelineKind::Hashtag(_ht) => Some(ui.add(
    514                     app_images::hashtag_image().fit_to_exact_size(egui::vec2(pfp_size, pfp_size)),
    515                 )),
    516 
    517                 TimelineKind::Profile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
    518 
    519                 TimelineKind::Search(_sq) => {
    520                     // TODO: show author pfp if author field set?
    521 
    522                     Some(ui.add(ui::side_panel::search_button(Some(top))))
    523                 }
    524 
    525                 TimelineKind::Universe
    526                 | TimelineKind::Algo(_)
    527                 | TimelineKind::Notifications(_)
    528                 | TimelineKind::Generic(_)
    529                 | TimelineKind::List(_) => Some(self.timeline_pfp(ui, kind, pfp_size)),
    530             },
    531             Route::Reply(_) => None,
    532             Route::Quote(_) => None,
    533             Route::Accounts(_as) => None,
    534             Route::ComposeNote => None,
    535             Route::AddColumn(_add_col_route) => None,
    536             Route::Support => None,
    537             Route::Relays => None,
    538             Route::Settings => None,
    539             Route::NewDeck => None,
    540             Route::EditDeck(_) => None,
    541             Route::EditProfile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
    542             Route::Search => Some(ui.add(ui::side_panel::search_button(Some(top)))),
    543             Route::Wallet(_) => None,
    544             Route::CustomizeZapAmount(_) => None,
    545             Route::Thread(thread_selection) => {
    546                 Some(self.thread_pfp(ui, thread_selection, pfp_size))
    547             }
    548             Route::RepostDecision(_) => None,
    549             Route::Following(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
    550             Route::FollowedBy(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
    551             Route::TosAcceptance => None,
    552             Route::Welcome => None,
    553             Route::Report(_) => None,
    554         }
    555     }
    556 
    557     fn show_profile(
    558         &mut self,
    559         ui: &mut egui::Ui,
    560         pubkey: &Pubkey,
    561         pfp_size: f32,
    562     ) -> egui::Response {
    563         let txn = Transaction::new(self.ndb).unwrap();
    564         if let Some(mut pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) {
    565             ui.add(&mut pfp)
    566         } else {
    567             ui.add(
    568                 &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url())
    569                     .size(pfp_size)
    570                     .sense(Sense::click()),
    571             )
    572         }
    573     }
    574 
    575     fn thread_pfp(
    576         &mut self,
    577         ui: &mut egui::Ui,
    578         selection: &ThreadSelection,
    579         pfp_size: f32,
    580     ) -> egui::Response {
    581         let txn = Transaction::new(self.ndb).unwrap();
    582 
    583         if let Ok(note) = self.ndb.get_note_by_id(&txn, selection.selected_or_root()) {
    584             if let Some(mut pfp) = self.pubkey_pfp(&txn, note.pubkey(), pfp_size) {
    585                 return ui.add(&mut pfp);
    586             }
    587         }
    588 
    589         ui.add(
    590             &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url())
    591                 .size(pfp_size),
    592         )
    593     }
    594 
    595     fn title_label_value(title: &str) -> egui::Label {
    596         egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()))
    597             .selectable(false)
    598     }
    599 
    600     fn title_label(&mut self, ui: &mut egui::Ui, top: &Route) {
    601         let column_title = top.title(self.i18n);
    602 
    603         match &column_title {
    604             ColumnTitle::Simple(title) => ui.add(Self::title_label_value(title)),
    605 
    606             ColumnTitle::NeedsDb(need_db) => {
    607                 let txn = Transaction::new(self.ndb).unwrap();
    608                 let title = need_db.title(&txn, self.ndb);
    609                 ui.add(Self::title_label_value(title))
    610             }
    611         };
    612     }
    613 
    614     pub fn show_move_button(&mut self, enable: bool) -> &mut Self {
    615         if enable {
    616             self.options |= Self::SHOW_MOVE;
    617         } else {
    618             self.options &= !Self::SHOW_MOVE;
    619         }
    620 
    621         self
    622     }
    623 
    624     pub fn show_delete_button(&mut self, enable: bool) -> &mut Self {
    625         if enable {
    626             self.options |= Self::SHOW_DELETE;
    627         } else {
    628             self.options &= !Self::SHOW_DELETE;
    629         }
    630 
    631         self
    632     }
    633 
    634     fn should_show_move_button(&self) -> bool {
    635         (self.options & Self::SHOW_MOVE) == Self::SHOW_MOVE
    636     }
    637 
    638     fn should_show_delete_button(&self) -> bool {
    639         (self.options & Self::SHOW_DELETE) == Self::SHOW_DELETE
    640     }
    641 
    642     fn title(&mut self, ui: &mut egui::Ui, top: &Route, navigating: bool) -> Option<TitleResponse> {
    643         let title_r = if !navigating {
    644             self.title_presentation(ui, top, 32.0)
    645         } else {
    646             None
    647         };
    648 
    649         ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
    650             if navigating {
    651                 self.title_presentation(ui, top, 32.0)
    652             } else {
    653                 let mut move_col: Option<usize> = None;
    654                 let mut remove_col = false;
    655 
    656                 if self.should_show_move_button() {
    657                     move_col = self.move_button_section(ui);
    658                 }
    659                 if self.should_show_delete_button() {
    660                     remove_col = self.delete_button_section(ui);
    661                 }
    662 
    663                 if let Some(col) = move_col {
    664                     Some(TitleResponse::MoveColumn(col))
    665                 } else if remove_col {
    666                     Some(TitleResponse::RemoveColumn)
    667                 } else {
    668                     None
    669                 }
    670             }
    671         })
    672         .inner
    673         .or(title_r)
    674     }
    675 
    676     fn title_presentation(
    677         &mut self,
    678         ui: &mut egui::Ui,
    679         top: &Route,
    680         pfp_size: f32,
    681     ) -> Option<TitleResponse> {
    682         let pfp_r = self
    683             .title_pfp(ui, top, pfp_size)
    684             .map(|r| r.on_hover_cursor(egui::CursorIcon::PointingHand));
    685 
    686         self.title_label(ui, top);
    687 
    688         pfp_r.and_then(|r| {
    689             if r.clicked() {
    690                 Some(TitleResponse::PfpClicked)
    691             } else {
    692                 None
    693             }
    694         })
    695     }
    696 }
    697 
    698 #[derive(Debug)]
    699 enum TitleResponse {
    700     RemoveColumn,
    701     PfpClicked,
    702     MoveColumn(usize),
    703 }
    704 
    705 fn prev<R>(xs: &[R]) -> Option<&R> {
    706     xs.get(xs.len().checked_sub(2)?)
    707 }
    708 
    709 fn grab_button() -> impl egui::Widget {
    710     |ui: &mut egui::Ui| -> egui::Response {
    711         let max_size = egui::vec2(20.0, 20.0);
    712         let helper = AnimationHelper::new(ui, "grab", max_size);
    713         let painter = ui.painter_at(helper.get_animation_rect());
    714         let min_circle_radius = 1.0;
    715         let cur_circle_radius = helper.scale_1d_pos(min_circle_radius);
    716         let horiz_spacing = 4.0;
    717         let vert_spacing = 10.0;
    718         let horiz_from_center = (horiz_spacing + min_circle_radius) / 2.0;
    719         let vert_from_center = (vert_spacing + min_circle_radius) / 2.0;
    720 
    721         let color = ui.style().visuals.noninteractive().fg_stroke.color;
    722 
    723         let middle_left = helper.scale_from_center(-horiz_from_center, 0.0);
    724         let middle_right = helper.scale_from_center(horiz_from_center, 0.0);
    725         let top_left = helper.scale_from_center(-horiz_from_center, -vert_from_center);
    726         let top_right = helper.scale_from_center(horiz_from_center, -vert_from_center);
    727         let bottom_left = helper.scale_from_center(-horiz_from_center, vert_from_center);
    728         let bottom_right = helper.scale_from_center(horiz_from_center, vert_from_center);
    729 
    730         painter.circle_filled(middle_left, cur_circle_radius, color);
    731         painter.circle_filled(middle_right, cur_circle_radius, color);
    732         painter.circle_filled(top_left, cur_circle_radius, color);
    733         painter.circle_filled(top_right, cur_circle_radius, color);
    734         painter.circle_filled(bottom_left, cur_circle_radius, color);
    735         painter.circle_filled(bottom_right, cur_circle_radius, color);
    736 
    737         helper.take_animation_response()
    738     }
    739 }