primitives.rs (48752B)
1 use gpui::{ 2 AnyElement, App, ClickEvent, Context, Corner, Div, ElementId, Entity, InteractiveElement, 3 IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, 4 prelude::FluentBuilder, px, relative, rgb, transparent_black, 5 }; 6 use gpui_component::{ 7 Icon, IconName, Sizable, Size, 8 button::{Button, ButtonCustomVariant, ButtonRounded, ButtonVariants, DropdownButton}, 9 input::{Input, InputState}, 10 menu::{DropdownMenu, PopupMenu}, 11 tab::{Tab, TabBar}, 12 }; 13 use std::rc::Rc; 14 15 use crate::APP_UI_THEME; 16 17 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 18 enum AppButtonVariant { 19 Secondary, 20 Primary, 21 } 22 23 pub struct AppSegmentButtonIconSpec { 24 pub id: &'static str, 25 pub label: SharedString, 26 pub icon: IconName, 27 } 28 29 impl AppSegmentButtonIconSpec { 30 pub fn new(id: &'static str, label: impl Into<SharedString>, icon: IconName) -> Self { 31 Self { 32 id, 33 label: label.into(), 34 icon, 35 } 36 } 37 } 38 39 pub struct AppIconButtonSpec { 40 pub id: &'static str, 41 pub label: SharedString, 42 pub icon: IconName, 43 } 44 45 impl AppIconButtonSpec { 46 pub fn new(id: &'static str, label: impl Into<SharedString>, icon: IconName) -> Self { 47 Self { 48 id, 49 label: label.into(), 50 icon, 51 } 52 } 53 } 54 55 pub struct AppCheckboxFieldSpec { 56 pub id: &'static str, 57 pub label: SharedString, 58 pub note: Option<SharedString>, 59 } 60 61 impl AppCheckboxFieldSpec { 62 pub fn new( 63 id: &'static str, 64 label: impl Into<SharedString>, 65 note: Option<impl Into<SharedString>>, 66 ) -> Self { 67 Self { 68 id, 69 label: label.into(), 70 note: note.map(Into::into), 71 } 72 } 73 } 74 75 pub struct AppFormFieldSpec { 76 pub label: SharedString, 77 pub note: Option<SharedString>, 78 } 79 80 impl AppFormFieldSpec { 81 pub fn new(label: impl Into<SharedString>, note: Option<impl Into<SharedString>>) -> Self { 82 Self { 83 label: label.into(), 84 note: note.map(Into::into), 85 } 86 } 87 } 88 89 pub struct AppUnderlineTabSpec { 90 pub label: SharedString, 91 } 92 93 impl AppUnderlineTabSpec { 94 pub fn new(label: impl Into<SharedString>) -> Self { 95 Self { 96 label: label.into(), 97 } 98 } 99 } 100 101 pub struct AppPillTabSpec { 102 pub label: SharedString, 103 } 104 105 impl AppPillTabSpec { 106 pub fn new(label: impl Into<SharedString>) -> Self { 107 Self { 108 label: label.into(), 109 } 110 } 111 } 112 113 #[derive(Clone, Debug, Eq, PartialEq)] 114 pub struct LabelValueRow { 115 pub label: SharedString, 116 pub value: SharedString, 117 } 118 119 impl LabelValueRow { 120 pub fn new(label: impl Into<SharedString>, value: impl Into<SharedString>) -> Self { 121 Self { 122 label: label.into(), 123 value: value.into(), 124 } 125 } 126 } 127 128 pub fn app_surface_window(background: u32, content: impl IntoElement) -> impl IntoElement { 129 div() 130 .size_full() 131 .overflow_hidden() 132 .bg(rgb(background)) 133 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 134 .child(content) 135 } 136 137 pub fn app_surface_sidebar(content: impl IntoElement) -> impl IntoElement { 138 div() 139 .h_full() 140 .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) 141 .child(content) 142 } 143 144 pub fn app_surface_panel(content: impl IntoElement) -> impl IntoElement { 145 div() 146 .w_full() 147 .bg(rgb(APP_UI_THEME.foundation.surfaces.chrome_background)) 148 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 149 .child(content) 150 } 151 152 pub fn app_surface_card(content: impl IntoElement) -> impl IntoElement { 153 div() 154 .w_full() 155 .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) 156 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 157 .child( 158 div() 159 .w_full() 160 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 161 .child(content), 162 ) 163 } 164 165 pub fn app_surface_card_section( 166 title: impl Into<SharedString>, 167 body: impl IntoElement, 168 ) -> impl IntoElement { 169 app_surface_card(app_form_section(title, body)) 170 } 171 172 pub fn app_focused_task_view( 173 title: impl Into<SharedString>, 174 body: impl IntoElement, 175 actions: impl IntoElement, 176 ) -> AnyElement { 177 app_focused_view( 178 APP_UI_THEME.shells.focused_task_max_width_px, 179 title, 180 body, 181 actions, 182 ) 183 } 184 185 pub fn app_focused_detail_view( 186 title: impl Into<SharedString>, 187 body: impl IntoElement, 188 actions: impl IntoElement, 189 ) -> AnyElement { 190 app_focused_view( 191 APP_UI_THEME.shells.focused_detail_max_width_px, 192 title, 193 body, 194 actions, 195 ) 196 } 197 198 fn app_focused_view( 199 max_width_px: f32, 200 title: impl Into<SharedString>, 201 body: impl IntoElement, 202 actions: impl IntoElement, 203 ) -> AnyElement { 204 div() 205 .w_full() 206 .max_w(px(max_width_px)) 207 .mx_auto() 208 .child(app_surface_card( 209 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 210 .w_full() 211 .child( 212 div() 213 .w_full() 214 .flex() 215 .items_start() 216 .justify_between() 217 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 218 .child(app_text_value(title)) 219 .child(actions), 220 ) 221 .child(body), 222 )) 223 .into_any_element() 224 } 225 226 pub fn app_stack_v(gap_px: f32) -> Div { 227 div().flex().flex_col().gap(px(gap_px)) 228 } 229 230 pub fn app_stack_h(gap_px: f32) -> Div { 231 div().flex().items_center().gap(px(gap_px)) 232 } 233 234 pub fn app_cluster(gap_px: f32) -> Div { 235 div().flex().flex_wrap().items_center().gap(px(gap_px)) 236 } 237 238 pub fn app_split_shell(sidebar: impl IntoElement, main_content: impl IntoElement) -> AnyElement { 239 let sidebar = sidebar.into_any_element(); 240 let main_content = main_content.into_any_element(); 241 242 app_surface_window( 243 APP_UI_THEME.foundation.surfaces.window_background, 244 div() 245 .size_full() 246 .overflow_hidden() 247 .flex() 248 .child(sidebar) 249 .child( 250 div() 251 .h_full() 252 .w(px(APP_UI_THEME.foundation.borders.divider_thickness_px)) 253 .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)), 254 ) 255 .child( 256 div() 257 .flex_1() 258 .h_full() 259 .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) 260 .overflow_hidden() 261 .child( 262 div() 263 .size_full() 264 .p(px(APP_UI_THEME.shells.home_window_padding_px)) 265 .child(main_content), 266 ), 267 ), 268 ) 269 .into_any_element() 270 } 271 272 pub fn app_scroll_panel( 273 id: &'static str, 274 content_padding_px: f32, 275 content_max_width_px: Option<f32>, 276 content: impl IntoElement, 277 ) -> AnyElement { 278 let content = content.into_any_element(); 279 let content: AnyElement = match content_max_width_px { 280 Some(content_max_width_px) => div() 281 .w_full() 282 .max_w(px(content_max_width_px)) 283 .mx_auto() 284 .child(content) 285 .into_any_element(), 286 None => div().w_full().child(content).into_any_element(), 287 }; 288 289 div() 290 .id(id) 291 .size_full() 292 .overflow_y_scroll() 293 .child( 294 div() 295 .w_full() 296 .when(content_padding_px > 0.0, |this| { 297 this.p(px(content_padding_px)) 298 }) 299 .child(content), 300 ) 301 .into_any_element() 302 } 303 304 pub fn app_divider() -> impl IntoElement { 305 div() 306 .w_full() 307 .h(px(APP_UI_THEME.foundation.borders.divider_thickness_px)) 308 .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)) 309 } 310 311 pub fn app_underline_tabs( 312 id: &'static str, 313 tabs: impl IntoIterator<Item = AppUnderlineTabSpec>, 314 selected_index: usize, 315 on_click: impl Fn(&usize, &mut Window, &mut App) + 'static, 316 ) -> impl IntoElement { 317 let tab_text_px = APP_UI_THEME.foundation.typography.body_text_px + 1.0; 318 let active_foreground = APP_UI_THEME.components.app_button.primary_colors.background; 319 let inactive_foreground = APP_UI_THEME.foundation.text.secondary; 320 let tab_gap_px = 16.0; 321 let tabs = tabs.into_iter().collect::<Vec<_>>(); 322 let tab_widths = tabs 323 .iter() 324 .map(|tab| app_underline_tab_width_px(&tab.label, tab_text_px)) 325 .collect::<Vec<_>>(); 326 let selected_width_px = tab_widths.get(selected_index).copied().unwrap_or(0.0); 327 let selected_offset_px = tab_widths.iter().take(selected_index).copied().sum::<f32>() 328 + tab_gap_px * selected_index as f32; 329 330 div() 331 .relative() 332 .w_full() 333 .child( 334 TabBar::new(id) 335 .underline() 336 .with_size(Size::Medium) 337 .w_full() 338 .children( 339 tabs.into_iter() 340 .enumerate() 341 .map(|(index, tab)| { 342 let is_selected = index == selected_index; 343 let foreground = if is_selected { 344 active_foreground 345 } else { 346 inactive_foreground 347 }; 348 349 Tab::new().child( 350 div() 351 .w(px(tab_widths.get(index).copied().unwrap_or(36.0))) 352 .text_size(px(tab_text_px)) 353 .font_weight(gpui::FontWeight::MEDIUM) 354 .text_color(rgb(foreground)) 355 .child(tab.label), 356 ) 357 }) 358 .collect::<Vec<_>>(), 359 ) 360 .on_click(on_click), 361 ) 362 .child( 363 div() 364 .absolute() 365 .left(px(selected_offset_px)) 366 .bottom_0() 367 .w(px(selected_width_px)) 368 .h(px(2.0)) 369 .rounded(px(1.0)) 370 .bg(rgb(active_foreground)), 371 ) 372 } 373 374 pub fn app_pill_tabs( 375 id: &'static str, 376 tabs: impl IntoIterator<Item = AppPillTabSpec>, 377 selected_index: usize, 378 on_click: impl Fn(&usize, &mut Window, &mut App) + 'static, 379 cx: &App, 380 ) -> impl IntoElement { 381 let on_click: Rc<dyn Fn(&usize, &mut Window, &mut App)> = Rc::new(on_click); 382 let tabs = tabs.into_iter().collect::<Vec<_>>(); 383 let height_px = APP_UI_THEME.components.app_button.sizing.height_px; 384 let radius_px = height_px / 2.0; 385 let horizontal_padding_px = APP_UI_THEME 386 .components 387 .app_button 388 .sizing 389 .compact_horizontal_padding_px; 390 let label_size_px = APP_UI_THEME.components.app_button.sizing.label_size_px; 391 let gap_px = APP_UI_THEME.foundation.spacing.micro_px; 392 let primary = APP_UI_THEME.components.app_button.primary_colors; 393 let inactive_foreground = APP_UI_THEME.foundation.text.secondary; 394 let inactive_hover_background = APP_UI_THEME.foundation.surfaces.card_background; 395 396 div() 397 .id(id) 398 .flex() 399 .items_center() 400 .w_full() 401 .gap(px(gap_px)) 402 .children(tabs.into_iter().enumerate().map(|(index, tab)| { 403 let is_selected = index == selected_index; 404 let foreground = if is_selected { 405 primary.foreground 406 } else { 407 inactive_foreground 408 }; 409 let variant = if is_selected { 410 ButtonCustomVariant::new(cx) 411 .color(rgb(primary.background).into()) 412 .foreground(rgb(primary.foreground).into()) 413 .border(transparent_black()) 414 .hover(rgb(primary.hover_background).into()) 415 .active(rgb(primary.active_background).into()) 416 } else { 417 ButtonCustomVariant::new(cx) 418 .color(transparent_black().into()) 419 .foreground(rgb(inactive_foreground).into()) 420 .border(transparent_black()) 421 .hover(rgb(inactive_hover_background).into()) 422 .active(rgb(inactive_hover_background).into()) 423 }; 424 let on_click = Rc::clone(&on_click); 425 426 Button::new((id, index)) 427 .custom(variant) 428 .h(px(height_px)) 429 .rounded(ButtonRounded::Size(px(radius_px))) 430 .on_click(move |_, window, cx| on_click(&index, window, cx)) 431 .child( 432 div() 433 .h_full() 434 .flex() 435 .items_center() 436 .justify_center() 437 .px(px(horizontal_padding_px)) 438 .text_size(px(label_size_px)) 439 .font_weight(gpui::FontWeight::MEDIUM) 440 .text_color(rgb(foreground)) 441 .child(tab.label), 442 ) 443 })) 444 } 445 446 fn app_underline_tab_width_px(label: &SharedString, tab_text_px: f32) -> f32 { 447 (label.as_ref().chars().count() as f32 * tab_text_px * 0.56).max(36.0) 448 } 449 450 pub fn app_heading_view(content: impl Into<SharedString>) -> impl IntoElement { 451 div() 452 .w_full() 453 .text_size(px(APP_UI_THEME.foundation.typography.startup_title_text_px)) 454 .font_weight(gpui::FontWeight::NORMAL) 455 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 456 .child(content.into()) 457 } 458 459 pub fn app_heading_section(content: impl Into<SharedString>) -> impl IntoElement { 460 div() 461 .w_full() 462 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 463 .font_weight(gpui::FontWeight::SEMIBOLD) 464 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 465 .child(content.into()) 466 } 467 468 pub fn app_text_body(content: impl Into<SharedString>) -> impl IntoElement { 469 div() 470 .w_full() 471 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 472 .line_height(relative(1.2)) 473 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 474 .child(content.into()) 475 } 476 477 pub fn app_text_body_subtle(content: impl Into<SharedString>) -> impl IntoElement { 478 div() 479 .w_full() 480 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 481 .line_height(relative(1.2)) 482 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 483 .child(content.into()) 484 } 485 486 pub fn app_text_label(content: impl Into<SharedString>) -> impl IntoElement { 487 div() 488 .w_full() 489 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 490 .font_weight(gpui::FontWeight::MEDIUM) 491 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 492 .child(content.into()) 493 } 494 495 pub fn app_text_value(content: impl Into<SharedString>) -> impl IntoElement { 496 div() 497 .w_full() 498 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) 499 .font_weight(gpui::FontWeight::BOLD) 500 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 501 .child(content.into()) 502 } 503 504 pub fn app_text_badge(content: impl Into<SharedString>) -> impl IntoElement { 505 div() 506 .w_full() 507 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 508 .font_weight(gpui::FontWeight::SEMIBOLD) 509 .text_color(rgb(APP_UI_THEME.foundation.text.accent)) 510 .child(content.into()) 511 } 512 513 pub fn utility_title_row(title: impl Into<SharedString>) -> impl IntoElement { 514 div() 515 .w_full() 516 .h(px(APP_UI_THEME.shells.utility_title_row_height_px)) 517 .flex() 518 .justify_center() 519 .items_center() 520 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 521 .font_weight(gpui::FontWeight::BOLD) 522 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 523 .child(title.into()) 524 } 525 526 pub fn label_value_list(rows: impl IntoIterator<Item = LabelValueRow>) -> impl IntoElement { 527 let rows = rows 528 .into_iter() 529 .map(|row| { 530 let line = format!("{}: {}", row.label, row.value); 531 div() 532 .w_full() 533 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 534 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 535 .child(line) 536 }) 537 .collect::<Vec<_>>(); 538 539 div() 540 .w_full() 541 .flex() 542 .flex_col() 543 .gap(px(APP_UI_THEME.shells.metadata_row_gap_px)) 544 .children(rows) 545 } 546 547 pub fn app_detail_row(label: impl Into<SharedString>, value: impl IntoElement) -> impl IntoElement { 548 div() 549 .w_full() 550 .flex() 551 .items_center() 552 .gap(px(APP_UI_THEME.shells.settings_account_detail_value_gap_px)) 553 .child( 554 div() 555 .text_size(px(APP_UI_THEME 556 .foundation 557 .typography 558 .settings_account_detail_text_px)) 559 .font_weight(gpui::FontWeight::SEMIBOLD) 560 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 561 .child(label.into()), 562 ) 563 .child(value) 564 } 565 566 pub fn app_form_section( 567 title: impl Into<SharedString>, 568 content: impl IntoElement, 569 ) -> impl IntoElement { 570 div() 571 .w_full() 572 .flex() 573 .flex_col() 574 .items_start() 575 .gap(px(APP_UI_THEME.foundation.spacing.small_px)) 576 .child(app_heading_section(title)) 577 .child(content) 578 } 579 580 pub fn app_form_field(spec: AppFormFieldSpec, field: impl IntoElement) -> impl IntoElement { 581 div() 582 .w_full() 583 .flex() 584 .flex_col() 585 .items_start() 586 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 587 .child(app_text_label(spec.label)) 588 .child(field) 589 .when_some(spec.note, |this, note| { 590 this.child(app_text_body_subtle(note)) 591 }) 592 } 593 594 pub fn app_form_input_text( 595 spec: AppFormFieldSpec, 596 input: &Entity<InputState>, 597 disabled: bool, 598 ) -> impl IntoElement { 599 app_form_field(spec, app_input_text(input, disabled).w_full()) 600 } 601 602 fn app_checkbox( 603 id: &'static str, 604 checked: bool, 605 cx: &App, 606 on_change: impl Fn(bool, &mut Window, &mut App) + 'static, 607 ) -> impl IntoElement { 608 let colors = APP_UI_THEME.components.app_checkbox_field; 609 let background = if checked { 610 colors.checked_background 611 } else { 612 colors.unchecked_background 613 }; 614 let border = if checked { 615 colors.checked_background 616 } else { 617 colors.unchecked_border 618 }; 619 let mut button = Button::new(id) 620 .custom( 621 ButtonCustomVariant::new(cx) 622 .color(rgb(background).into()) 623 .foreground(rgb(colors.check_foreground).into()) 624 .border(rgb(border).into()) 625 .hover(rgb(background).into()) 626 .active(rgb(background).into()), 627 ) 628 .rounded(ButtonRounded::Size(px(colors.corner_radius_px))) 629 .with_size(Size::Size(px(colors.size_px))) 630 .on_click(move |_, window, cx| on_change(!checked, window, cx)); 631 632 if checked { 633 button = button.icon( 634 Icon::new(IconName::Check) 635 .with_size(Size::Size(px(colors.icon_size_px))) 636 .text_color(rgb(colors.check_foreground)), 637 ); 638 } 639 640 button.tab_stop(false) 641 } 642 643 pub fn app_checkbox_button( 644 id: &'static str, 645 checked: bool, 646 cx: &App, 647 on_change: impl Fn(bool, &mut Window, &mut App) + 'static, 648 ) -> impl IntoElement { 649 app_checkbox(id, checked, cx, on_change) 650 } 651 652 pub fn app_checkbox_field( 653 spec: AppCheckboxFieldSpec, 654 checked: bool, 655 cx: &App, 656 on_change: impl Fn(bool, &mut Window, &mut App) + 'static, 657 ) -> impl IntoElement { 658 let checkbox_id = spec.id; 659 let checkbox_label = spec.label; 660 let checkbox_note = spec.note; 661 let row_text_px = APP_UI_THEME.foundation.typography.settings_row_text_px; 662 let note_text_px = APP_UI_THEME.foundation.typography.utility_title_text_px; 663 let note_indent_px = APP_UI_THEME.components.app_checkbox_field.size_px 664 + APP_UI_THEME.shells.settings_checkbox_label_gap_px; 665 let on_change = Rc::new(on_change); 666 667 div() 668 .w_full() 669 .flex() 670 .flex_col() 671 .gap(px(APP_UI_THEME.foundation.spacing.micro_px)) 672 .child( 673 Button::new((checkbox_id, 0usize)) 674 .custom( 675 ButtonCustomVariant::new(cx) 676 .color(transparent_black().into()) 677 .foreground(rgb(APP_UI_THEME.foundation.text.primary).into()) 678 .border(transparent_black()) 679 .hover(transparent_black().into()) 680 .active(transparent_black().into()), 681 ) 682 .rounded(ButtonRounded::Size(px(0.0))) 683 .w_full() 684 .p(px(0.0)) 685 .on_click({ 686 let on_change = Rc::clone(&on_change); 687 move |_, window, cx| on_change(!checked, window, cx) 688 }) 689 .child( 690 div() 691 .w_full() 692 .flex() 693 .items_start() 694 .gap(px(APP_UI_THEME.shells.settings_checkbox_label_gap_px)) 695 .child(app_checkbox(checkbox_id, checked, cx, { 696 let on_change = Rc::clone(&on_change); 697 move |checked, window, cx| on_change(checked, window, cx) 698 })) 699 .child( 700 div() 701 .min_w_0() 702 .text_size(px(row_text_px)) 703 .line_height(relative(1.1)) 704 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 705 .child(checkbox_label), 706 ), 707 ), 708 ) 709 .when_some(checkbox_note, |this, note| { 710 this.child( 711 div() 712 .w_full() 713 .pl(px(note_indent_px)) 714 .min_w_0() 715 .text_size(px(note_text_px)) 716 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 717 .child(note), 718 ) 719 }) 720 } 721 722 pub fn app_segment_button_icon( 723 spec: AppSegmentButtonIconSpec, 724 is_active: bool, 725 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 726 cx: &App, 727 ) -> impl IntoElement { 728 let colors = APP_UI_THEME.components.app_segment_button_icon.colors; 729 let sizing = APP_UI_THEME.components.app_segment_button_icon.sizing; 730 let background = if is_active { 731 colors.active_background 732 } else { 733 colors.inactive_background 734 }; 735 let foreground = if is_active { 736 colors.active_foreground 737 } else { 738 colors.inactive_foreground 739 }; 740 741 Button::new(spec.id) 742 .custom( 743 ButtonCustomVariant::new(cx) 744 .color(rgb(background).into()) 745 .foreground(rgb(foreground).into()) 746 .border(transparent_black()) 747 .hover(rgb(background).into()) 748 .active(rgb(background).into()), 749 ) 750 .rounded(ButtonRounded::Size(px(sizing.corner_radius_px))) 751 .h(px(sizing.height_px)) 752 .min_w(px(sizing.height_px)) 753 .on_click(on_click) 754 .child( 755 div() 756 .h_full() 757 .flex() 758 .flex_col() 759 .justify_between() 760 .items_center() 761 .px(px(sizing.inner_padding_px)) 762 .py(px(sizing.inner_padding_px)) 763 .child( 764 Icon::new(spec.icon) 765 .with_size(Size::Size(px(sizing.icon_size_px))) 766 .text_color(rgb(foreground)), 767 ) 768 .child( 769 div() 770 .text_size(px(sizing.label_size_px)) 771 .text_color(rgb(foreground)) 772 .child(spec.label), 773 ), 774 ) 775 } 776 777 pub fn app_input_text(input: &Entity<InputState>, disabled: bool) -> Input { 778 let tokens = APP_UI_THEME.components.app_input_text; 779 let background = if disabled { 780 tokens.disabled_background 781 } else { 782 tokens.background 783 }; 784 let foreground = if disabled { 785 APP_UI_THEME.foundation.text.secondary 786 } else { 787 APP_UI_THEME.foundation.text.primary 788 }; 789 790 Input::new(input) 791 .with_size(Size::Medium) 792 .disabled(disabled) 793 .focus_bordered(true) 794 .bg(rgb(background)) 795 .text_color(rgb(foreground)) 796 .border_color(rgb(tokens.border)) 797 .border_1() 798 .rounded(px(tokens.corner_radius_px)) 799 } 800 801 pub fn app_button_secondary( 802 id: impl Into<ElementId>, 803 label: impl Into<SharedString>, 804 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 805 cx: &App, 806 ) -> impl IntoElement { 807 app_button_label( 808 app_button_base(id, AppButtonVariant::Secondary, on_click, cx), 809 label.into(), 810 APP_UI_THEME 811 .components 812 .app_button 813 .sizing 814 .horizontal_padding_px, 815 AppButtonVariant::Secondary, 816 ) 817 } 818 819 pub fn app_button_secondary_full_width( 820 id: impl Into<ElementId>, 821 label: impl Into<SharedString>, 822 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 823 cx: &App, 824 ) -> impl IntoElement { 825 app_button_label( 826 app_button_base(id, AppButtonVariant::Secondary, on_click, cx), 827 label.into(), 828 APP_UI_THEME 829 .components 830 .app_button 831 .sizing 832 .horizontal_padding_px, 833 AppButtonVariant::Secondary, 834 ) 835 .w_full() 836 } 837 838 pub fn app_button_secondary_disabled( 839 id: impl Into<ElementId>, 840 label: impl Into<SharedString>, 841 cx: &App, 842 ) -> impl IntoElement { 843 app_button_label( 844 app_button_base_disabled(id, AppButtonVariant::Secondary, cx), 845 label.into(), 846 APP_UI_THEME 847 .components 848 .app_button 849 .sizing 850 .horizontal_padding_px, 851 AppButtonVariant::Secondary, 852 ) 853 } 854 855 pub fn app_button_primary( 856 id: impl Into<ElementId>, 857 label: impl Into<SharedString>, 858 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 859 cx: &App, 860 ) -> impl IntoElement { 861 app_button_label( 862 app_button_base(id, AppButtonVariant::Primary, on_click, cx), 863 label.into(), 864 APP_UI_THEME 865 .components 866 .app_button 867 .sizing 868 .horizontal_padding_px, 869 AppButtonVariant::Primary, 870 ) 871 } 872 873 pub fn app_button_primary_compact( 874 id: impl Into<ElementId>, 875 label: impl Into<SharedString>, 876 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 877 cx: &App, 878 ) -> impl IntoElement { 879 app_button_label( 880 app_button_base(id, AppButtonVariant::Primary, on_click, cx), 881 label.into(), 882 APP_UI_THEME 883 .components 884 .app_button 885 .sizing 886 .compact_horizontal_padding_px, 887 AppButtonVariant::Primary, 888 ) 889 } 890 891 pub fn app_button_primary_compact_disabled( 892 id: impl Into<ElementId>, 893 label: impl Into<SharedString>, 894 cx: &App, 895 ) -> impl IntoElement { 896 app_button_label( 897 app_button_base_disabled(id, AppButtonVariant::Primary, cx), 898 label.into(), 899 APP_UI_THEME 900 .components 901 .app_button 902 .sizing 903 .compact_horizontal_padding_px, 904 AppButtonVariant::Primary, 905 ) 906 } 907 908 pub fn app_button_primary_full_width( 909 id: impl Into<ElementId>, 910 label: impl Into<SharedString>, 911 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 912 cx: &App, 913 ) -> impl IntoElement { 914 app_button_label( 915 app_button_base(id, AppButtonVariant::Primary, on_click, cx), 916 label.into(), 917 APP_UI_THEME 918 .components 919 .app_button 920 .sizing 921 .horizontal_padding_px, 922 AppButtonVariant::Primary, 923 ) 924 .w_full() 925 } 926 927 pub fn app_button_primary_disabled( 928 id: impl Into<ElementId>, 929 label: impl Into<SharedString>, 930 cx: &App, 931 ) -> impl IntoElement { 932 app_button_label( 933 app_button_base_disabled(id, AppButtonVariant::Primary, cx), 934 label.into(), 935 APP_UI_THEME 936 .components 937 .app_button 938 .sizing 939 .horizontal_padding_px, 940 AppButtonVariant::Primary, 941 ) 942 } 943 944 pub fn app_button_compact( 945 id: impl Into<ElementId>, 946 label: impl Into<SharedString>, 947 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 948 cx: &App, 949 ) -> impl IntoElement { 950 app_button_label( 951 app_button_base(id, AppButtonVariant::Secondary, on_click, cx), 952 label.into(), 953 APP_UI_THEME 954 .components 955 .app_button 956 .sizing 957 .compact_horizontal_padding_px, 958 AppButtonVariant::Secondary, 959 ) 960 } 961 962 pub fn app_button_square_dropdown_secondary( 963 id: &'static str, 964 menu: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, 965 _cx: &App, 966 ) -> impl IntoElement { 967 let sizing = APP_UI_THEME.components.app_button.sizing; 968 let colors = APP_UI_THEME.components.app_button.secondary_colors; 969 970 div() 971 .w(px(sizing.square_width_px)) 972 .h(px(sizing.height_px)) 973 .rounded(px(sizing.corner_radius_px)) 974 .bg(rgb(colors.background)) 975 .overflow_hidden() 976 .child( 977 DropdownButton::new(id) 978 .button( 979 Button::new((id, 0usize)) 980 .tab_stop(false) 981 .w(px(0.0)) 982 .overflow_hidden() 983 .ghost() 984 .with_size(Size::Size(px(sizing.square_width_px))), 985 ) 986 .dropdown_menu(menu) 987 .ghost() 988 .rounded(ButtonRounded::Size(px(sizing.corner_radius_px))) 989 .with_size(Size::Size(px(sizing.square_width_px))), 990 ) 991 } 992 993 pub fn app_button_ellipsis_menu( 994 id: &'static str, 995 menu: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, 996 _cx: &App, 997 ) -> impl IntoElement { 998 let sizing = APP_UI_THEME.components.app_button.sizing; 999 1000 Button::new(id) 1001 .ghost() 1002 .rounded(ButtonRounded::Size(px(APP_UI_THEME 1003 .foundation 1004 .radii 1005 .medium_px))) 1006 .with_size(Size::Size(px(sizing.square_width_px))) 1007 .tab_stop(false) 1008 .child( 1009 Icon::new(IconName::Ellipsis) 1010 .with_size(Size::Size(px(sizing.icon_size_px))) 1011 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)), 1012 ) 1013 .dropdown_menu_with_anchor(Corner::BottomRight, menu) 1014 } 1015 1016 pub fn app_button_sidebar_account_menu( 1017 id: &'static str, 1018 label: impl Into<SharedString>, 1019 menu: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, 1020 cx: &App, 1021 ) -> impl IntoElement { 1022 let label = label.into(); 1023 let sizing = APP_UI_THEME.components.app_button.sizing; 1024 let row = APP_UI_THEME.components.app_account_selector_row; 1025 let horizontal_padding_px = APP_UI_THEME 1026 .shells 1027 .settings_account_sidebar_footer_button_gap_px; 1028 let icon_label_gap_px = APP_UI_THEME.foundation.spacing.micro_px; 1029 let icon_size = Size::Size(px(sizing.icon_size_px)); 1030 let width_px = APP_UI_THEME.shells.home_sidebar_width_px 1031 - (APP_UI_THEME.shells.home_window_padding_px * 2.0); 1032 1033 Button::new(id) 1034 .custom( 1035 ButtonCustomVariant::new(cx) 1036 .color(rgb(row.inactive_background).into()) 1037 .foreground(rgb(APP_UI_THEME.foundation.text.secondary).into()) 1038 .border(transparent_black()) 1039 .hover(rgb(row.active_background).into()) 1040 .active(rgb(row.active_background).into()), 1041 ) 1042 .w(px(width_px)) 1043 .h(px(sizing.height_px)) 1044 .p(px(0.0)) 1045 .rounded(ButtonRounded::Size(px(APP_UI_THEME 1046 .shells 1047 .settings_account_sidebar_button_corner_radius_px))) 1048 .tab_stop(false) 1049 .child( 1050 div() 1051 .w(px(width_px)) 1052 .h_full() 1053 .px(px(horizontal_padding_px)) 1054 .flex() 1055 .items_center() 1056 .justify_between() 1057 .gap(px(APP_UI_THEME 1058 .shells 1059 .settings_account_sidebar_button_gap_px)) 1060 .child( 1061 div() 1062 .flex_1() 1063 .flex() 1064 .items_center() 1065 .min_w_0() 1066 .gap(px(icon_label_gap_px)) 1067 .child( 1068 div().flex_none().child( 1069 Icon::new(IconName::CircleUser) 1070 .with_size(icon_size) 1071 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)), 1072 ), 1073 ) 1074 .child( 1075 div() 1076 .min_w_0() 1077 .truncate() 1078 .text_size(px(APP_UI_THEME 1079 .foundation 1080 .typography 1081 .settings_row_text_px)) 1082 .font_weight(gpui::FontWeight::MEDIUM) 1083 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 1084 .child(label), 1085 ), 1086 ) 1087 .child( 1088 div().flex_none().child( 1089 Icon::new(IconName::ChevronsUpDown) 1090 .with_size(icon_size) 1091 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)), 1092 ), 1093 ), 1094 ) 1095 .dropdown_menu_with_anchor(Corner::TopLeft, menu) 1096 } 1097 1098 fn app_button_label( 1099 button: Button, 1100 label: SharedString, 1101 horizontal_padding_px: f32, 1102 variant: AppButtonVariant, 1103 ) -> Button { 1104 let sizing = APP_UI_THEME.components.app_button.sizing; 1105 let colors = app_button_colors(variant); 1106 button.child( 1107 div() 1108 .h_full() 1109 .flex() 1110 .items_center() 1111 .justify_center() 1112 .px(px(horizontal_padding_px)) 1113 .whitespace_nowrap() 1114 .text_size(px(sizing.label_size_px)) 1115 .text_color(rgb(colors.foreground)) 1116 .child(label), 1117 ) 1118 } 1119 1120 pub fn app_button_icon( 1121 spec: AppIconButtonSpec, 1122 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 1123 cx: &App, 1124 ) -> impl IntoElement { 1125 let sizing = APP_UI_THEME.components.app_button.sizing; 1126 let colors = app_button_colors(AppButtonVariant::Secondary); 1127 1128 app_button_base(spec.id, AppButtonVariant::Secondary, on_click, cx) 1129 .with_size(Size::Size(px(sizing.square_width_px))) 1130 .tooltip(spec.label) 1131 .icon( 1132 Icon::new(spec.icon) 1133 .with_size(Size::Size(px(sizing.icon_size_px))) 1134 .text_color(rgb(colors.foreground)), 1135 ) 1136 } 1137 1138 pub fn app_status_indicator(color: u32) -> impl IntoElement { 1139 let sizing = APP_UI_THEME.components.app_status_indicator; 1140 1141 div() 1142 .size(px(sizing.size_px)) 1143 .bg(rgb(color)) 1144 .rounded(px(sizing.size_px / 2.0)) 1145 } 1146 1147 pub fn app_button_text( 1148 id: impl Into<ElementId>, 1149 label: impl Into<SharedString>, 1150 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 1151 cx: &App, 1152 ) -> impl IntoElement { 1153 Button::new(id) 1154 .custom( 1155 ButtonCustomVariant::new(cx) 1156 .color(transparent_black().into()) 1157 .foreground(rgb(APP_UI_THEME.foundation.text.secondary).into()) 1158 .border(transparent_black()) 1159 .hover(transparent_black().into()) 1160 .active(transparent_black().into()), 1161 ) 1162 .rounded(ButtonRounded::Size(px(0.0))) 1163 .on_click(on_click) 1164 .child( 1165 div() 1166 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 1167 .font_weight(gpui::FontWeight::MEDIUM) 1168 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 1169 .child(label.into()), 1170 ) 1171 } 1172 1173 pub fn app_button_choice( 1174 id: impl Into<ElementId>, 1175 label: impl Into<SharedString>, 1176 is_active: bool, 1177 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 1178 cx: &App, 1179 ) -> AnyElement { 1180 let variant = if is_active { 1181 AppButtonVariant::Primary 1182 } else { 1183 AppButtonVariant::Secondary 1184 }; 1185 1186 app_button_label( 1187 app_button_base(id, variant, on_click, cx), 1188 label.into(), 1189 APP_UI_THEME 1190 .components 1191 .app_button 1192 .sizing 1193 .compact_horizontal_padding_px, 1194 variant, 1195 ) 1196 .into_any_element() 1197 } 1198 1199 pub fn app_button_list_row( 1200 id: impl Into<ElementId>, 1201 title: impl Into<SharedString>, 1202 subtitle: Option<SharedString>, 1203 is_selected: bool, 1204 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 1205 cx: &App, 1206 ) -> impl IntoElement { 1207 let selected_background = rgb(APP_UI_THEME.foundation.surfaces.window_background); 1208 1209 Button::new(id) 1210 .custom( 1211 ButtonCustomVariant::new(cx) 1212 .color(if is_selected { 1213 selected_background.into() 1214 } else { 1215 transparent_black().into() 1216 }) 1217 .foreground(rgb(APP_UI_THEME.foundation.text.primary).into()) 1218 .border(transparent_black()) 1219 .hover(selected_background.into()) 1220 .active(selected_background.into()), 1221 ) 1222 .rounded(ButtonRounded::Size(px(APP_UI_THEME 1223 .foundation 1224 .radii 1225 .medium_px))) 1226 .flex_1() 1227 .min_w_0() 1228 .on_click(on_click) 1229 .child( 1230 div() 1231 .w_full() 1232 .flex() 1233 .flex_col() 1234 .items_start() 1235 .gap(px(4.0)) 1236 .px(px(8.0)) 1237 .py(px(6.0)) 1238 .child( 1239 div() 1240 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 1241 .font_weight(gpui::FontWeight::MEDIUM) 1242 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 1243 .child(title.into()), 1244 ) 1245 .when_some(subtitle, |this, subtitle| { 1246 this.child( 1247 div() 1248 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 1249 .line_height(relative(1.2)) 1250 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 1251 .child(subtitle), 1252 ) 1253 }), 1254 ) 1255 } 1256 1257 pub fn app_button_account_selector_row( 1258 id: impl Into<ElementId>, 1259 title: impl Into<SharedString>, 1260 subtitle: impl Into<SharedString>, 1261 is_selected: bool, 1262 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 1263 cx: &App, 1264 ) -> impl IntoElement { 1265 let tokens = APP_UI_THEME.components.app_account_selector_row; 1266 let background = if is_selected { 1267 tokens.active_background 1268 } else { 1269 tokens.inactive_background 1270 }; 1271 1272 Button::new(id) 1273 .custom( 1274 ButtonCustomVariant::new(cx) 1275 .color(rgb(background).into()) 1276 .foreground(rgb(APP_UI_THEME.foundation.text.primary).into()) 1277 .border(transparent_black()) 1278 .hover(rgb(background).into()) 1279 .active(rgb(background).into()), 1280 ) 1281 .rounded(ButtonRounded::Size(px(APP_UI_THEME 1282 .shells 1283 .settings_account_sidebar_button_corner_radius_px))) 1284 .h(px(APP_UI_THEME 1285 .shells 1286 .settings_account_sidebar_button_height_px)) 1287 .w_full() 1288 .min_w_0() 1289 .p(px(0.0)) 1290 .on_click(on_click) 1291 .child( 1292 div() 1293 .w_full() 1294 .h_full() 1295 .min_w_0() 1296 .flex() 1297 .items_center() 1298 .gap(px(APP_UI_THEME 1299 .shells 1300 .settings_account_sidebar_button_gap_px)) 1301 .px(px(APP_UI_THEME.foundation.spacing.small_px)) 1302 .child( 1303 div() 1304 .size(px(APP_UI_THEME 1305 .shells 1306 .settings_account_sidebar_avatar_size_px)) 1307 .rounded_full() 1308 .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)) 1309 .flex_shrink_0(), 1310 ) 1311 .child( 1312 div() 1313 .min_w_0() 1314 .flex() 1315 .flex_col() 1316 .items_start() 1317 .gap(px(APP_UI_THEME 1318 .shells 1319 .settings_account_identity_text_gap_px)) 1320 .child( 1321 div() 1322 .w_full() 1323 .min_w_0() 1324 .max_w_full() 1325 .overflow_hidden() 1326 .text_ellipsis() 1327 .whitespace_nowrap() 1328 .text_size(px(APP_UI_THEME 1329 .foundation 1330 .typography 1331 .settings_account_identity_text_px 1332 + 1.0)) 1333 .font_weight(gpui::FontWeight::LIGHT) 1334 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 1335 .child(title.into()), 1336 ) 1337 .child( 1338 div() 1339 .w_full() 1340 .min_w_0() 1341 .max_w_full() 1342 .overflow_hidden() 1343 .text_ellipsis() 1344 .whitespace_nowrap() 1345 .text_size(px(APP_UI_THEME 1346 .foundation 1347 .typography 1348 .utility_title_text_px)) 1349 .font_weight(gpui::FontWeight::NORMAL) 1350 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 1351 .child(subtitle.into()), 1352 ), 1353 ), 1354 ) 1355 } 1356 1357 pub fn app_button_card( 1358 id: impl Into<ElementId>, 1359 is_selected: bool, 1360 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 1361 cx: &App, 1362 content: impl IntoElement, 1363 ) -> impl IntoElement { 1364 let selected_background = rgb(APP_UI_THEME.foundation.surfaces.window_background); 1365 1366 Button::new(id) 1367 .custom( 1368 ButtonCustomVariant::new(cx) 1369 .color(rgb(APP_UI_THEME.foundation.surfaces.card_background).into()) 1370 .foreground(rgb(APP_UI_THEME.foundation.text.primary).into()) 1371 .border(transparent_black()) 1372 .hover(selected_background.into()) 1373 .active(selected_background.into()), 1374 ) 1375 .rounded(ButtonRounded::Size(px(APP_UI_THEME 1376 .foundation 1377 .radii 1378 .medium_px))) 1379 .w_full() 1380 .on_click(on_click) 1381 .child( 1382 div() 1383 .w_full() 1384 .min_w_0() 1385 .bg(rgb(if is_selected { 1386 APP_UI_THEME.foundation.surfaces.window_background 1387 } else { 1388 APP_UI_THEME.foundation.surfaces.card_background 1389 })) 1390 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 1391 .child(content), 1392 ) 1393 } 1394 1395 fn app_button_base( 1396 id: impl Into<ElementId>, 1397 variant: AppButtonVariant, 1398 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 1399 cx: &App, 1400 ) -> Button { 1401 let sizing = APP_UI_THEME.components.app_button.sizing; 1402 let colors = app_button_colors(variant); 1403 let hover_background = if colors.hover_changes_background { 1404 colors.hover_background 1405 } else { 1406 colors.background 1407 }; 1408 1409 Button::new(id) 1410 .custom( 1411 ButtonCustomVariant::new(cx) 1412 .color(rgb(colors.background).into()) 1413 .foreground(rgb(colors.foreground).into()) 1414 .border(transparent_black()) 1415 .hover(rgb(hover_background).into()) 1416 .active(rgb(colors.active_background).into()), 1417 ) 1418 .rounded(ButtonRounded::Size(px(sizing.corner_radius_px))) 1419 .h(px(sizing.height_px)) 1420 .on_click(on_click) 1421 } 1422 1423 fn app_button_base_disabled( 1424 id: impl Into<ElementId>, 1425 variant: AppButtonVariant, 1426 cx: &App, 1427 ) -> Button { 1428 let sizing = APP_UI_THEME.components.app_button.sizing; 1429 let colors = app_button_disabled_colors(variant); 1430 1431 Button::new(id) 1432 .custom( 1433 ButtonCustomVariant::new(cx) 1434 .color(rgb(colors.background).into()) 1435 .foreground(rgb(colors.foreground).into()) 1436 .border(transparent_black()) 1437 .hover(rgb(colors.hover_background).into()) 1438 .active(rgb(colors.active_background).into()), 1439 ) 1440 .rounded(ButtonRounded::Size(px(sizing.corner_radius_px))) 1441 .h(px(sizing.height_px)) 1442 } 1443 1444 fn app_button_colors(variant: AppButtonVariant) -> crate::AppButtonColors { 1445 match variant { 1446 AppButtonVariant::Secondary => APP_UI_THEME.components.app_button.secondary_colors, 1447 AppButtonVariant::Primary => APP_UI_THEME.components.app_button.primary_colors, 1448 } 1449 } 1450 1451 fn app_button_disabled_colors(variant: AppButtonVariant) -> crate::AppButtonColors { 1452 match variant { 1453 AppButtonVariant::Secondary | AppButtonVariant::Primary => { 1454 APP_UI_THEME.components.app_button.primary_disabled_colors 1455 } 1456 } 1457 } 1458 1459 #[cfg(test)] 1460 mod tests { 1461 use gpui_component::IconName; 1462 1463 use super::{ 1464 AppCheckboxFieldSpec, AppFormFieldSpec, AppIconButtonSpec, AppSegmentButtonIconSpec, 1465 }; 1466 1467 #[test] 1468 fn icon_segment_spec_preserves_id_and_label() { 1469 let spec = AppSegmentButtonIconSpec::new("settings", "Settings", IconName::Settings2); 1470 1471 assert_eq!(spec.id, "settings"); 1472 assert_eq!(spec.label.as_ref(), "Settings"); 1473 } 1474 1475 #[test] 1476 fn checkbox_field_spec_preserves_optional_note() { 1477 let spec = AppCheckboxFieldSpec::new("launch", "Launch at login", Some("Optional note")); 1478 1479 assert_eq!(spec.id, "launch"); 1480 assert_eq!(spec.label.as_ref(), "Launch at login"); 1481 assert_eq!( 1482 spec.note.as_ref().map(|note| note.as_ref()), 1483 Some("Optional note") 1484 ); 1485 } 1486 1487 #[test] 1488 fn form_field_spec_preserves_optional_note() { 1489 let spec = AppFormFieldSpec::new("Farm name", Some("Saved locally")); 1490 1491 assert_eq!(spec.label.as_ref(), "Farm name"); 1492 assert_eq!( 1493 spec.note.as_ref().map(|note| note.as_ref()), 1494 Some("Saved locally") 1495 ); 1496 } 1497 1498 #[test] 1499 fn icon_button_spec_preserves_id_label_and_icon() { 1500 let spec = AppIconButtonSpec::new("more", "More actions", IconName::ChevronDown); 1501 1502 assert_eq!(spec.id, "more"); 1503 assert_eq!(spec.label.as_ref(), "More actions"); 1504 assert!(matches!(spec.icon, IconName::ChevronDown)); 1505 } 1506 }