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 }