notedeck

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

header.rs (23886B)


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