notedeck

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

header.rs (24017B)


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