notedeck

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

header.rs (25368B)


      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         }
    552     }
    553 
    554     fn show_profile(
    555         &mut self,
    556         ui: &mut egui::Ui,
    557         pubkey: &Pubkey,
    558         pfp_size: f32,
    559     ) -> egui::Response {
    560         let txn = Transaction::new(self.ndb).unwrap();
    561         if let Some(mut pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) {
    562             ui.add(&mut pfp)
    563         } else {
    564             ui.add(
    565                 &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url())
    566                     .size(pfp_size)
    567                     .sense(Sense::click()),
    568             )
    569         }
    570     }
    571 
    572     fn thread_pfp(
    573         &mut self,
    574         ui: &mut egui::Ui,
    575         selection: &ThreadSelection,
    576         pfp_size: f32,
    577     ) -> egui::Response {
    578         let txn = Transaction::new(self.ndb).unwrap();
    579 
    580         if let Ok(note) = self.ndb.get_note_by_id(&txn, selection.selected_or_root()) {
    581             if let Some(mut pfp) = self.pubkey_pfp(&txn, note.pubkey(), pfp_size) {
    582                 return ui.add(&mut pfp);
    583             }
    584         }
    585 
    586         ui.add(
    587             &mut ProfilePic::new(self.img_cache, self.jobs, notedeck::profile::no_pfp_url())
    588                 .size(pfp_size),
    589         )
    590     }
    591 
    592     fn title_label_value(title: &str) -> egui::Label {
    593         egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()))
    594             .selectable(false)
    595     }
    596 
    597     fn title_label(&mut self, ui: &mut egui::Ui, top: &Route) {
    598         let column_title = top.title(self.i18n);
    599 
    600         match &column_title {
    601             ColumnTitle::Simple(title) => ui.add(Self::title_label_value(title)),
    602 
    603             ColumnTitle::NeedsDb(need_db) => {
    604                 let txn = Transaction::new(self.ndb).unwrap();
    605                 let title = need_db.title(&txn, self.ndb);
    606                 ui.add(Self::title_label_value(title))
    607             }
    608         };
    609     }
    610 
    611     pub fn show_move_button(&mut self, enable: bool) -> &mut Self {
    612         if enable {
    613             self.options |= Self::SHOW_MOVE;
    614         } else {
    615             self.options &= !Self::SHOW_MOVE;
    616         }
    617 
    618         self
    619     }
    620 
    621     pub fn show_delete_button(&mut self, enable: bool) -> &mut Self {
    622         if enable {
    623             self.options |= Self::SHOW_DELETE;
    624         } else {
    625             self.options &= !Self::SHOW_DELETE;
    626         }
    627 
    628         self
    629     }
    630 
    631     fn should_show_move_button(&self) -> bool {
    632         (self.options & Self::SHOW_MOVE) == Self::SHOW_MOVE
    633     }
    634 
    635     fn should_show_delete_button(&self) -> bool {
    636         (self.options & Self::SHOW_DELETE) == Self::SHOW_DELETE
    637     }
    638 
    639     fn title(&mut self, ui: &mut egui::Ui, top: &Route, navigating: bool) -> Option<TitleResponse> {
    640         let title_r = if !navigating {
    641             self.title_presentation(ui, top, 32.0)
    642         } else {
    643             None
    644         };
    645 
    646         ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
    647             if navigating {
    648                 self.title_presentation(ui, top, 32.0)
    649             } else {
    650                 let mut move_col: Option<usize> = None;
    651                 let mut remove_col = false;
    652 
    653                 if self.should_show_move_button() {
    654                     move_col = self.move_button_section(ui);
    655                 }
    656                 if self.should_show_delete_button() {
    657                     remove_col = self.delete_button_section(ui);
    658                 }
    659 
    660                 if let Some(col) = move_col {
    661                     Some(TitleResponse::MoveColumn(col))
    662                 } else if remove_col {
    663                     Some(TitleResponse::RemoveColumn)
    664                 } else {
    665                     None
    666                 }
    667             }
    668         })
    669         .inner
    670         .or(title_r)
    671     }
    672 
    673     fn title_presentation(
    674         &mut self,
    675         ui: &mut egui::Ui,
    676         top: &Route,
    677         pfp_size: f32,
    678     ) -> Option<TitleResponse> {
    679         let pfp_r = self
    680             .title_pfp(ui, top, pfp_size)
    681             .map(|r| r.on_hover_cursor(egui::CursorIcon::PointingHand));
    682 
    683         self.title_label(ui, top);
    684 
    685         pfp_r.and_then(|r| {
    686             if r.clicked() {
    687                 Some(TitleResponse::PfpClicked)
    688             } else {
    689                 None
    690             }
    691         })
    692     }
    693 }
    694 
    695 #[derive(Debug)]
    696 enum TitleResponse {
    697     RemoveColumn,
    698     PfpClicked,
    699     MoveColumn(usize),
    700 }
    701 
    702 fn prev<R>(xs: &[R]) -> Option<&R> {
    703     xs.get(xs.len().checked_sub(2)?)
    704 }
    705 
    706 fn grab_button() -> impl egui::Widget {
    707     |ui: &mut egui::Ui| -> egui::Response {
    708         let max_size = egui::vec2(20.0, 20.0);
    709         let helper = AnimationHelper::new(ui, "grab", max_size);
    710         let painter = ui.painter_at(helper.get_animation_rect());
    711         let min_circle_radius = 1.0;
    712         let cur_circle_radius = helper.scale_1d_pos(min_circle_radius);
    713         let horiz_spacing = 4.0;
    714         let vert_spacing = 10.0;
    715         let horiz_from_center = (horiz_spacing + min_circle_radius) / 2.0;
    716         let vert_from_center = (vert_spacing + min_circle_radius) / 2.0;
    717 
    718         let color = ui.style().visuals.noninteractive().fg_stroke.color;
    719 
    720         let middle_left = helper.scale_from_center(-horiz_from_center, 0.0);
    721         let middle_right = helper.scale_from_center(horiz_from_center, 0.0);
    722         let top_left = helper.scale_from_center(-horiz_from_center, -vert_from_center);
    723         let top_right = helper.scale_from_center(horiz_from_center, -vert_from_center);
    724         let bottom_left = helper.scale_from_center(-horiz_from_center, vert_from_center);
    725         let bottom_right = helper.scale_from_center(horiz_from_center, vert_from_center);
    726 
    727         painter.circle_filled(middle_left, cur_circle_radius, color);
    728         painter.circle_filled(middle_right, cur_circle_radius, color);
    729         painter.circle_filled(top_left, cur_circle_radius, color);
    730         painter.circle_filled(top_right, cur_circle_radius, color);
    731         painter.circle_filled(bottom_left, cur_circle_radius, color);
    732         painter.circle_filled(bottom_right, cur_circle_radius, color);
    733 
    734         helper.take_animation_response()
    735     }
    736 }