side_panel.rs (18621B)
1 use egui::{ 2 vec2, Color32, InnerResponse, Label, Layout, Margin, RichText, Separator, Stroke, Widget, 3 }; 4 use tracing::info; 5 6 use crate::{ 7 accounts::AccountsRoute, 8 app_style, colors, 9 column::{Column, Columns}, 10 imgcache::ImageCache, 11 route::Route, 12 support::Support, 13 user_account::UserAccount, 14 Damus, 15 }; 16 17 use super::{ 18 anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, 19 profile::preview::get_account_url, 20 ProfilePic, View, 21 }; 22 23 pub static SIDE_PANEL_WIDTH: f32 = 68.0; 24 static ICON_WIDTH: f32 = 40.0; 25 26 pub struct DesktopSidePanel<'a> { 27 ndb: &'a nostrdb::Ndb, 28 img_cache: &'a mut ImageCache, 29 selected_account: Option<&'a UserAccount>, 30 } 31 32 impl View for DesktopSidePanel<'_> { 33 fn ui(&mut self, ui: &mut egui::Ui) { 34 self.show(ui); 35 } 36 } 37 38 #[derive(Debug, Eq, PartialEq, Clone, Copy)] 39 pub enum SidePanelAction { 40 Panel, 41 Account, 42 Settings, 43 Columns, 44 ComposeNote, 45 Search, 46 ExpandSidePanel, 47 Support, 48 } 49 50 pub struct SidePanelResponse { 51 pub response: egui::Response, 52 pub action: SidePanelAction, 53 } 54 55 impl SidePanelResponse { 56 fn new(action: SidePanelAction, response: egui::Response) -> Self { 57 SidePanelResponse { action, response } 58 } 59 } 60 61 impl<'a> DesktopSidePanel<'a> { 62 pub fn new( 63 ndb: &'a nostrdb::Ndb, 64 img_cache: &'a mut ImageCache, 65 selected_account: Option<&'a UserAccount>, 66 ) -> Self { 67 Self { 68 ndb, 69 img_cache, 70 selected_account, 71 } 72 } 73 74 pub fn show(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { 75 let mut frame = egui::Frame::none().inner_margin(Margin::same(8.0)); 76 77 if !ui.visuals().dark_mode { 78 frame = frame.fill(colors::ALMOST_WHITE); 79 } 80 81 frame.show(ui, |ui| self.show_inner(ui)).inner 82 } 83 84 fn show_inner(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { 85 let dark_mode = ui.ctx().style().visuals.dark_mode; 86 87 let inner = ui 88 .vertical(|ui| { 89 let top_resp = ui 90 .with_layout(Layout::top_down(egui::Align::Center), |ui| { 91 // macos needs a bit of space to make room for window 92 // minimize/close buttons 93 if cfg!(target_os = "macos") { 94 ui.add_space(24.0); 95 } 96 97 let expand_resp = ui.add(expand_side_panel_button()); 98 ui.add_space(4.0); 99 ui.add(milestone_name()); 100 ui.add_space(16.0); 101 let is_interactive = self 102 .selected_account 103 .is_some_and(|s| s.secret_key.is_some()); 104 let compose_resp = ui.add(compose_note_button(is_interactive)); 105 let compose_resp = if is_interactive { 106 compose_resp 107 } else { 108 compose_resp.on_hover_cursor(egui::CursorIcon::NotAllowed) 109 }; 110 // let search_resp = ui.add(search_button()); 111 let column_resp = ui.add(add_column_button(dark_mode)); 112 113 ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); 114 115 if expand_resp.clicked() { 116 Some(InnerResponse::new( 117 SidePanelAction::ExpandSidePanel, 118 expand_resp, 119 )) 120 } else if compose_resp.clicked() { 121 Some(InnerResponse::new( 122 SidePanelAction::ComposeNote, 123 compose_resp, 124 )) 125 // } else if search_resp.clicked() { 126 // Some(InnerResponse::new(SidePanelAction::Search, search_resp)) 127 } else if column_resp.clicked() { 128 Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) 129 } else { 130 None 131 } 132 }) 133 .inner; 134 135 let (pfp_resp, bottom_resp) = ui 136 .with_layout(Layout::bottom_up(egui::Align::Center), |ui| { 137 let pfp_resp = self.pfp_button(ui); 138 let settings_resp = ui.add(settings_button(dark_mode)); 139 140 if let Some(new_visuals) = app_style::user_requested_visuals_change( 141 super::is_oled(), 142 ui.ctx().style().visuals.dark_mode, 143 ui, 144 ) { 145 ui.ctx().set_visuals(new_visuals) 146 } 147 148 let support_resp = ui.add(support_button()); 149 150 let optional_inner = if pfp_resp.clicked() { 151 Some(egui::InnerResponse::new( 152 SidePanelAction::Account, 153 pfp_resp.clone(), 154 )) 155 } else if settings_resp.clicked() || settings_resp.hovered() { 156 Some(egui::InnerResponse::new( 157 SidePanelAction::Settings, 158 settings_resp, 159 )) 160 } else if support_resp.clicked() { 161 Some(egui::InnerResponse::new( 162 SidePanelAction::Support, 163 support_resp, 164 )) 165 } else { 166 None 167 }; 168 169 (pfp_resp, optional_inner) 170 }) 171 .inner; 172 173 if let Some(bottom_inner) = bottom_resp { 174 bottom_inner 175 } else if let Some(top_inner) = top_resp { 176 top_inner 177 } else { 178 egui::InnerResponse::new(SidePanelAction::Panel, pfp_resp) 179 } 180 }) 181 .inner; 182 183 SidePanelResponse::new(inner.inner, inner.response) 184 } 185 186 fn pfp_button(&mut self, ui: &mut egui::Ui) -> egui::Response { 187 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 188 let helper = AnimationHelper::new(ui, "pfp-button", vec2(max_size, max_size)); 189 190 let min_pfp_size = ICON_WIDTH; 191 let cur_pfp_size = helper.scale_1d_pos(min_pfp_size); 192 193 let txn = nostrdb::Transaction::new(self.ndb).expect("should be able to create txn"); 194 let profile_url = get_account_url(&txn, self.ndb, self.selected_account); 195 196 let widget = ProfilePic::new(self.img_cache, profile_url).size(cur_pfp_size); 197 198 ui.put(helper.get_animation_rect(), widget); 199 200 helper.take_animation_response() 201 } 202 203 pub fn perform_action(columns: &mut Columns, support: &mut Support, action: SidePanelAction) { 204 let router = columns.get_first_router(); 205 match action { 206 SidePanelAction::Panel => {} // TODO 207 SidePanelAction::Account => { 208 if router 209 .routes() 210 .iter() 211 .any(|&r| r == Route::Accounts(AccountsRoute::Accounts)) 212 { 213 // return if we are already routing to accounts 214 router.go_back(); 215 } else { 216 router.route_to(Route::accounts()); 217 } 218 } 219 SidePanelAction::Settings => { 220 if router.routes().iter().any(|&r| r == Route::Relays) { 221 // return if we are already routing to accounts 222 router.go_back(); 223 } else { 224 router.route_to(Route::relays()); 225 } 226 } 227 SidePanelAction::Columns => { 228 if router 229 .routes() 230 .iter() 231 .any(|&r| matches!(r, Route::AddColumn(_))) 232 { 233 router.go_back(); 234 } else { 235 columns.new_column_picker(); 236 } 237 } 238 SidePanelAction::ComposeNote => { 239 if router.routes().iter().any(|&r| r == Route::ComposeNote) { 240 router.go_back(); 241 } else { 242 router.route_to(Route::ComposeNote); 243 } 244 } 245 SidePanelAction::Search => { 246 // TODO 247 info!("Clicked search button"); 248 } 249 SidePanelAction::ExpandSidePanel => { 250 // TODO 251 info!("Clicked expand side panel button"); 252 } 253 SidePanelAction::Support => { 254 if router.routes().iter().any(|&r| r == Route::Support) { 255 router.go_back(); 256 } else { 257 support.refresh(); 258 router.route_to(Route::Support); 259 } 260 } 261 } 262 } 263 } 264 265 fn settings_button(dark_mode: bool) -> impl Widget { 266 move |ui: &mut egui::Ui| { 267 let img_size = 24.0; 268 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 269 let img_data = if dark_mode { 270 egui::include_image!("../../assets/icons/settings_dark_4x.png") 271 } else { 272 egui::include_image!("../../assets/icons/settings_light_4x.png") 273 }; 274 let img = egui::Image::new(img_data).max_width(img_size); 275 276 let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size)); 277 278 let cur_img_size = helper.scale_1d_pos(img_size); 279 img.paint_at( 280 ui, 281 helper 282 .get_animation_rect() 283 .shrink((max_size - cur_img_size) / 2.0), 284 ); 285 286 helper.take_animation_response() 287 } 288 } 289 290 fn add_column_button(dark_mode: bool) -> impl Widget { 291 move |ui: &mut egui::Ui| { 292 let img_size = 24.0; 293 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 294 295 let img_data = if dark_mode { 296 egui::include_image!("../../assets/icons/add_column_dark_4x.png") 297 } else { 298 egui::include_image!("../../assets/icons/add_column_light_4x.png") 299 }; 300 301 let img = egui::Image::new(img_data).max_width(img_size); 302 303 let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size)); 304 305 let cur_img_size = helper.scale_1d_pos(img_size); 306 img.paint_at( 307 ui, 308 helper 309 .get_animation_rect() 310 .shrink((max_size - cur_img_size) / 2.0), 311 ); 312 313 helper.take_animation_response() 314 } 315 } 316 317 fn compose_note_button(interactive: bool) -> impl Widget { 318 move |ui: &mut egui::Ui| -> egui::Response { 319 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 320 321 let min_outer_circle_diameter = 40.0; 322 let min_plus_sign_size = 14.0; // length of the plus sign 323 let min_line_width = 2.25; // width of the plus sign 324 325 let helper = if interactive { 326 AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size)) 327 } else { 328 AnimationHelper::no_animation(ui, vec2(max_size, max_size)) 329 }; 330 331 let painter = ui.painter_at(helper.get_animation_rect()); 332 333 let use_background_radius = helper.scale_radius(min_outer_circle_diameter); 334 let use_line_width = helper.scale_1d_pos(min_line_width); 335 let use_edge_circle_radius = helper.scale_radius(min_line_width); 336 337 let fill_color = if interactive { 338 colors::PINK 339 } else { 340 ui.visuals().noninteractive().bg_fill 341 }; 342 343 painter.circle_filled(helper.center(), use_background_radius, fill_color); 344 345 let min_half_plus_sign_size = min_plus_sign_size / 2.0; 346 let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size); 347 let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size); 348 let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0); 349 let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0); 350 351 painter.line_segment( 352 [north_edge, south_edge], 353 Stroke::new(use_line_width, Color32::WHITE), 354 ); 355 painter.line_segment( 356 [west_edge, east_edge], 357 Stroke::new(use_line_width, Color32::WHITE), 358 ); 359 painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE); 360 painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE); 361 painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE); 362 painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE); 363 364 helper.take_animation_response() 365 } 366 } 367 368 #[allow(unused)] 369 fn search_button() -> impl Widget { 370 |ui: &mut egui::Ui| -> egui::Response { 371 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 372 let min_line_width_circle = 1.5; // width of the magnifying glass 373 let min_line_width_handle = 1.5; 374 let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size)); 375 376 let painter = ui.painter_at(helper.get_animation_rect()); 377 378 let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle); 379 let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle); 380 let min_outer_circle_radius = helper.scale_radius(15.0); 381 let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius); 382 let min_handle_length = 7.0; 383 let cur_handle_length = helper.scale_1d_pos(min_handle_length); 384 385 let circle_center = helper.scale_from_center(-2.0, -2.0); 386 387 let handle_vec = vec2( 388 std::f32::consts::FRAC_1_SQRT_2, 389 std::f32::consts::FRAC_1_SQRT_2, 390 ); 391 392 let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0)); 393 let handle_pos_2 = 394 circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length)); 395 396 let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY); 397 let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY); 398 399 painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke); 400 painter.circle( 401 circle_center, 402 min_outer_circle_radius, 403 ui.style().visuals.widgets.inactive.weak_bg_fill, 404 circle_stroke, 405 ); 406 407 helper.take_animation_response() 408 } 409 } 410 411 // TODO: convert to responsive button when expanded side panel impl is finished 412 fn expand_side_panel_button() -> impl Widget { 413 |ui: &mut egui::Ui| -> egui::Response { 414 let img_size = 40.0; 415 let img_data = egui::include_image!("../../assets/damus_rounded_80.png"); 416 let img = egui::Image::new(img_data).max_width(img_size); 417 418 ui.add(img) 419 } 420 } 421 422 fn support_button() -> impl Widget { 423 |ui: &mut egui::Ui| -> egui::Response { 424 let img_size = 16.0; 425 426 let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget 427 let img_data = if ui.visuals().dark_mode { 428 egui::include_image!("../../assets/icons/help_icon_dark_4x.png") 429 } else { 430 egui::include_image!("../../assets/icons/help_icon_inverted_4x.png") 431 }; 432 let img = egui::Image::new(img_data).max_width(img_size); 433 434 let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size)); 435 436 let cur_img_size = helper.scale_1d_pos(img_size); 437 img.paint_at( 438 ui, 439 helper 440 .get_animation_rect() 441 .shrink((max_size - cur_img_size) / 2.0), 442 ); 443 444 helper.take_animation_response() 445 } 446 } 447 448 fn milestone_name() -> impl Widget { 449 |ui: &mut egui::Ui| -> egui::Response { 450 ui.vertical_centered(|ui| { 451 let font = egui::FontId::new( 452 crate::app_style::get_font_size( 453 ui.ctx(), 454 &crate::app_style::NotedeckTextStyle::Tiny, 455 ), 456 egui::FontFamily::Name(crate::fonts::NamedFontFamily::Bold.as_str().into()), 457 ); 458 ui.add(Label::new( 459 RichText::new("ALPHA") 460 .color(crate::colors::GRAY_SECONDARY) 461 .font(font), 462 ).selectable(false)).on_hover_text("Notedeck is an alpha product. Expect bugs and contact us when you run into issues.").on_hover_cursor(egui::CursorIcon::Help) 463 }) 464 .inner 465 } 466 } 467 468 mod preview { 469 470 use egui_extras::{Size, StripBuilder}; 471 472 use crate::{ 473 test_data, 474 ui::{Preview, PreviewConfig}, 475 }; 476 477 use super::*; 478 479 pub struct DesktopSidePanelPreview { 480 app: Damus, 481 } 482 483 impl DesktopSidePanelPreview { 484 fn new() -> Self { 485 let mut app = test_data::test_app(); 486 app.columns.add_column(Column::new(vec![Route::accounts()])); 487 DesktopSidePanelPreview { app } 488 } 489 } 490 491 impl View for DesktopSidePanelPreview { 492 fn ui(&mut self, ui: &mut egui::Ui) { 493 StripBuilder::new(ui) 494 .size(Size::exact(SIDE_PANEL_WIDTH)) 495 .sizes(Size::remainder(), 0) 496 .clip(true) 497 .horizontal(|mut strip| { 498 strip.cell(|ui| { 499 let mut panel = DesktopSidePanel::new( 500 &self.app.ndb, 501 &mut self.app.img_cache, 502 self.app.accounts.get_selected_account(), 503 ); 504 let response = panel.show(ui); 505 506 DesktopSidePanel::perform_action( 507 &mut self.app.columns, 508 &mut self.app.support, 509 response.action, 510 ); 511 }); 512 }); 513 } 514 } 515 516 impl Preview for DesktopSidePanel<'_> { 517 type Prev = DesktopSidePanelPreview; 518 519 fn preview(_cfg: PreviewConfig) -> Self::Prev { 520 DesktopSidePanelPreview::new() 521 } 522 } 523 }