app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 46722d35de8b7e59dd38558773cec7aa5f5ff639
parent 78076d3dd6028098a64278712b7049706f698e6d
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Apr 2026 18:58:18 +0000

ui: add shared layout primitives

Diffstat:
Mcrates/launchers/desktop/src/window.rs | 380++++++++++++++++++++++++++++++-------------------------------------------------
Mcrates/shared/ui/src/lib.rs | 11++++++-----
Mcrates/shared/ui/src/primitives.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 232 insertions(+), 242 deletions(-)

diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -1,9 +1,8 @@ use gpui::{ Animation, AnimationExt, AnyElement, App, AppContext, Bounds, ClickEvent, Context, Entity, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Subscription, Timer, Window, WindowBackgroundAppearance, - WindowBounds, WindowOptions, div, prelude::FluentBuilder, px, relative, rgb, size, - transparent_black, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + Timer, Window, WindowBackgroundAppearance, WindowBounds, WindowOptions, div, + prelude::FluentBuilder, px, relative, rgb, size, transparent_black, }; use gpui_component::{ IconName, Root, @@ -38,10 +37,11 @@ use radroots_app_ui::{ app_button_compact as action_button_compact, app_button_icon as action_icon_button, app_button_primary as action_button_primary, app_button_primary_disabled as action_button_primary_disabled, - app_button_secondary as action_button, app_checkbox_field, app_divider as section_divider, - app_form_field, app_form_input_text, app_form_section, app_heading_section, app_heading_view, - app_input_text as app_text_input, app_segment_button_icon as icon_segment_button, - app_shared_label_text, app_shared_text, app_status_indicator as status_indicator, + app_button_secondary as action_button, app_checkbox_field, app_cluster, + app_divider as section_divider, app_form_field, app_form_input_text, app_form_section, + app_heading_section, app_heading_view, app_input_text as app_text_input, app_scroll_panel, + app_segment_button_icon as icon_segment_button, app_shared_label_text, app_shared_text, + app_split_shell, app_stack_h, app_stack_v, app_status_indicator as status_indicator, app_surface_card, app_surface_card_section as home_card, app_surface_panel, app_surface_sidebar, app_surface_window as app_window_shell, app_text_badge as settings_badge_text, app_text_body_subtle as home_body_text, @@ -1289,7 +1289,7 @@ impl HomeView { .into_any_element(), }; - home_shell_frame( + app_split_shell( home_sidebar( runtime, cx.listener(|this, _, _, cx| this.select_farmer_section(FarmerSection::Today, cx)), @@ -1299,12 +1299,13 @@ impl HomeView { cx, ) .into_any_element(), - div() - .id(home_content_scroll_id(selected_farmer_section)) - .size_full() - .overflow_y_scroll() - .child(main_content) - .into_any_element(), + app_scroll_panel( + home_content_scroll_id(selected_farmer_section), + 0.0, + None, + main_content, + ) + .into_any_element(), ) .into_any_element() } @@ -1317,13 +1318,10 @@ impl HomeView { let projection = &runtime.products_projection; let summary = &projection.list.summary; - div() + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) .w_full() .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) .mx_auto() - .flex() - .flex_col() - .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) .child(products_title_row( runtime, action_button_primary( @@ -3793,55 +3791,48 @@ impl SettingsWindowView { } fn about_panel(&self) -> impl IntoElement { - div() - .id("settings-panel-scroll") - .size_full() - .overflow_y_scroll() - .child( - div() - .p(px(APP_UI_THEME.shells.settings_content_padding_px)) - .size_full() - .flex() - .flex_col() - .py_12() - .child( - div() - .w_full() - .flex() - .flex_col() - .justify_between() - .gap(px(APP_UI_THEME.shells.settings_account_main_stack_gap_px)) - .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) - .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) - .child(app_shared_text( - AppTextKey::SettingsAboutPlaceholderTopPrimary, - )) - .child(app_shared_text( - AppTextKey::SettingsAboutPlaceholderTopSecondary, - )) - .child(app_shared_text( - AppTextKey::SettingsAboutPlaceholderTopTertiary, - )), - ) - .child(section_divider()) - .child( - div() - .w_full() - .py_12() - .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) - .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) - .child(app_shared_text(AppTextKey::SettingsAboutPlaceholderMiddle)), - ) - .child(section_divider()) - .child( - div() - .w_full() - .py_12() - .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) - .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) - .child(app_shared_text(AppTextKey::SettingsAboutPlaceholderBottom)), - ), - ) + app_scroll_panel( + "settings-panel-scroll", + APP_UI_THEME.shells.settings_content_padding_px, + None, + app_stack_v(APP_UI_THEME.shells.settings_account_main_stack_gap_px) + .size_full() + .py_12() + .child( + app_stack_v(APP_UI_THEME.shells.settings_account_main_stack_gap_px) + .w_full() + .justify_between() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(app_shared_text( + AppTextKey::SettingsAboutPlaceholderTopPrimary, + )) + .child(app_shared_text( + AppTextKey::SettingsAboutPlaceholderTopSecondary, + )) + .child(app_shared_text( + AppTextKey::SettingsAboutPlaceholderTopTertiary, + )), + ) + .child(section_divider()) + .child( + div() + .w_full() + .py_12() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(app_shared_text(AppTextKey::SettingsAboutPlaceholderMiddle)), + ) + .child(section_divider()) + .child( + div() + .w_full() + .py_12() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(app_shared_text(AppTextKey::SettingsAboutPlaceholderBottom)), + ), + ) } fn settings_panel_content( @@ -3868,30 +3859,24 @@ impl Render for SettingsWindowView { app_window_shell( APP_UI_THEME.foundation.surfaces.panel_background, - div() + app_stack_v(0.0) .size_full() .bg(rgb(APP_UI_THEME.foundation.surfaces.panel_background)) .overflow_hidden() - .flex() - .flex_col() .child( - div() + app_stack_v(0.0) .w_full() .h(px(APP_UI_THEME.shells.settings_chrome_height_px)) .bg(rgb(APP_UI_THEME.foundation.surfaces.chrome_background)) - .flex() - .flex_col() .child(utility_title_row(app_shared_text( AppTextKey::SettingsTitle, ))) .child( - div() + app_cluster(APP_UI_THEME.shells.settings_navigation_row_gap_px) .w_full() - .flex() .justify_center() .pt(px(APP_UI_THEME.shells.settings_navigation_row_padding_px)) .pb(px(APP_UI_THEME.shells.settings_navigation_row_padding_px)) - .gap(px(APP_UI_THEME.shells.settings_navigation_row_gap_px)) .children(navigation_buttons), ), ) @@ -4058,21 +4043,14 @@ fn holding_home_shell(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { ); } - home_shell_frame( + app_split_shell( holding_home_sidebar(runtime).into_any_element(), - div() - .size_full() - .child( - div() - .w_full() - .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) - .mx_auto() - .flex() - .flex_col() - .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) - .child(home_status_row(&home_status)) - .children(sections), - ) + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) + .mx_auto() + .child(home_status_row(&home_status)) + .children(sections) .into_any_element(), ) } @@ -4129,82 +4107,74 @@ fn startup_home_shell( .items_center() .justify_center() .child( - div() + app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) .w_full() .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) .mx_auto() - .flex() - .flex_col() .items_center() - .gap(px(APP_UI_THEME.shells.startup_stack_gap_px)) .child(startup_home_title(surface)) .child(startup_home_tagline()) .child(match surface { - StartupHomeSurface::ContinuePrompt => div() - .flex() - .flex_col() - .items_center() - .gap(px(APP_UI_THEME.shells.startup_stack_gap_px)) - .child(action_button_primary( - "home-continue", - app_shared_text( - AppTextKey::HomeSetupContinueAction, - ), - on_continue, - cx, - )) - .when_some(startup_notice, |this, error| { - this.child( - div() - .w_full() - .text_center() - .child(home_body_text(error.to_owned())), - ) - }) - .into_any_element(), - StartupHomeSurface::IdentityChoice => div() - .flex() - .flex_col() - .items_center() - .gap(px(APP_UI_THEME.shells.startup_stack_gap_px)) - .child(action_button_primary( - "home-generate-key", - app_shared_text( - AppTextKey::HomeSetupGenerateKeyAction, - ), - on_generate_key, - cx, - )) - .child(action_button( - "home-connect-signer", - app_shared_text( - AppTextKey::HomeSetupConnectSignerAction, - ), - on_connect_signer, - cx, - )) - .when_some(startup_notice, |this, error| { - this.child( - div() - .w_full() - .text_center() - .child(home_body_text(error.to_owned())), - ) - }) - .into_any_element(), - StartupHomeSurface::GenerateKeyStarting => div() - .flex() - .flex_col() - .items_center() - .gap(px(APP_UI_THEME.shells.startup_stack_gap_px)) - .child(action_button_primary_disabled( - "home-generate-key", - app_shared_text( - AppTextKey::HomeSetupGenerateKeyAction, - ), - cx, - )) - .into_any_element(), + StartupHomeSurface::ContinuePrompt => { + app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) + .items_center() + .child(action_button_primary( + "home-continue", + app_shared_text( + AppTextKey::HomeSetupContinueAction, + ), + on_continue, + cx, + )) + .when_some(startup_notice, |this, error| { + this.child( + div().w_full().text_center().child( + home_body_text(error.to_owned()), + ), + ) + }) + .into_any_element() + } + StartupHomeSurface::IdentityChoice => { + app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) + .items_center() + .child(action_button_primary( + "home-generate-key", + app_shared_text( + AppTextKey::HomeSetupGenerateKeyAction, + ), + on_generate_key, + cx, + )) + .child(action_button( + "home-connect-signer", + app_shared_text( + AppTextKey::HomeSetupConnectSignerAction, + ), + on_connect_signer, + cx, + )) + .when_some(startup_notice, |this, error| { + this.child( + div().w_full().text_center().child( + home_body_text(error.to_owned()), + ), + ) + }) + .into_any_element() + } + StartupHomeSurface::GenerateKeyStarting => { + app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) + .items_center() + .child(action_button_primary_disabled( + "home-generate-key", + app_shared_text( + AppTextKey::HomeSetupGenerateKeyAction, + ), + cx, + )) + .into_any_element() + } StartupHomeSurface::SignerEntry => { startup_signer_entry_surface( signer_entry, @@ -4217,12 +4187,9 @@ fn startup_home_shell( .into_any_element() } StartupHomeSurface::IssueCard => app_surface_card( - div() + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) .w_full() - .flex() - .flex_col() .items_center() - .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) .child(app_heading_section(app_shared_text( AppTextKey::MetadataStartupIssue, ))) @@ -4289,12 +4256,9 @@ fn startup_signer_entry_surface( preview.is_ok() && matches!(connect_state, StartupSignerConnectState::Idle); let source_input_is_editable = startup_signer_source_input_is_editable(connect_state); - div() + app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) .w_full() - .flex() - .flex_col() .items_center() - .gap(px(APP_UI_THEME.shells.startup_stack_gap_px)) .when_some(signer_entry, |this, signer_entry| { this.child( div() @@ -4310,12 +4274,9 @@ fn startup_signer_entry_surface( }) .when_some(preview.as_ref().ok(), |this, preview| { this.child(app_surface_card( - div() + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) .w_full() - .flex() - .flex_col() .items_center() - .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) .child(app_heading_section(app_shared_text( AppTextKey::HomeSetupSignerReviewTitle, ))) @@ -4341,12 +4302,9 @@ fn startup_signer_entry_surface( }) .when_some(startup_signer_status_spec(connect_state), |this, status| { this.child(app_surface_card( - div() + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) .w_full() - .flex() - .flex_col() .items_center() - .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) .child(app_heading_section(app_shared_text(status.0))) .child( status @@ -4593,36 +4551,6 @@ async fn run_startup_signer_pending_poll( } } -fn home_shell_frame(sidebar: AnyElement, main_content: AnyElement) -> impl IntoElement { - app_window_shell( - APP_UI_THEME.foundation.surfaces.window_background, - div() - .size_full() - .overflow_hidden() - .flex() - .child(sidebar) - .child( - div() - .h_full() - .w(px(APP_UI_THEME.foundation.borders.divider_thickness_px)) - .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)), - ) - .child( - div() - .flex_1() - .h_full() - .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) - .overflow_hidden() - .child( - div() - .size_full() - .p(px(APP_UI_THEME.shells.home_window_padding_px)) - .child(main_content), - ), - ), - ) -} - fn home_sidebar( runtime: &DesktopAppRuntimeSummary, on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -4935,17 +4863,12 @@ fn products_title_row( runtime: &DesktopAppRuntimeSummary, add_product_action: AnyElement, ) -> impl IntoElement { - div() + app_stack_h(APP_UI_THEME.shells.home_stack_gap_px) .w_full() - .flex() .items_end() .justify_between() - .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) .child( - div() - .flex() - .flex_col() - .gap(px(4.0)) + app_stack_v(4.0) .child( div() .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) @@ -6483,28 +6406,15 @@ fn settings_dynamic_action_button( fn settings_inventory_panel(intro_key: AppTextKey, cards: Vec<AnyElement>) -> impl IntoElement { let content_max_width_px = 560.0; - div() - .id("settings-panel-scroll") - .size_full() - .overflow_y_scroll() - .child( - div() - .w_full() - .p(px(APP_UI_THEME.shells.settings_content_padding_px)) - .flex() - .flex_col() - .items_center() - .child( - div() - .w_full() - .max_w(px(content_max_width_px)) - .flex() - .flex_col() - .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) - .child(home_body_text(app_shared_text(intro_key))) - .children(cards), - ), - ) + app_scroll_panel( + "settings-panel-scroll", + APP_UI_THEME.shells.settings_content_padding_px, + Some(content_max_width_px), + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .child(home_body_text(app_shared_text(intro_key))) + .children(cards), + ) } #[cfg(test)] diff --git a/crates/shared/ui/src/lib.rs b/crates/shared/ui/src/lib.rs @@ -7,11 +7,12 @@ mod theme; pub use primitives::{ AppCheckboxFieldSpec, AppFormFieldSpec, AppSegmentButtonIconSpec, LabelValueRow, app_button_compact, app_button_icon, app_button_primary, app_button_primary_disabled, - app_button_secondary, app_checkbox_field, app_divider, app_form_field, app_form_input_text, - app_form_section, app_heading_section, app_heading_view, app_input_text, - app_segment_button_icon, app_status_indicator, app_surface_card, app_surface_card_section, - app_surface_panel, app_surface_sidebar, app_surface_window, app_text_badge, app_text_body, - app_text_body_subtle, app_text_label, app_text_value, label_value_list, utility_title_row, + app_button_secondary, app_checkbox_field, app_cluster, app_divider, app_form_field, + app_form_input_text, app_form_section, app_heading_section, app_heading_view, app_input_text, + app_scroll_panel, app_segment_button_icon, app_split_shell, app_stack_h, app_stack_v, + app_status_indicator, app_surface_card, app_surface_card_section, app_surface_panel, + app_surface_sidebar, app_surface_window, app_text_badge, app_text_body, app_text_body_subtle, + app_text_label, app_text_value, label_value_list, utility_title_row, }; pub use text::{ app_shared_label_text, app_shared_text, runtime_metadata_rows, settings_about_status_rows, diff --git a/crates/shared/ui/src/primitives.rs b/crates/shared/ui/src/primitives.rs @@ -1,6 +1,7 @@ use gpui::{ - App, ClickEvent, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, - prelude::FluentBuilder, px, relative, rgb, transparent_black, + AnyElement, App, ClickEvent, Div, Entity, InteractiveElement, IntoElement, ParentElement, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, + relative, rgb, transparent_black, }; use gpui_component::{ Icon, IconName, Sizable, Size, @@ -126,6 +127,84 @@ pub fn app_surface_card_section( app_surface_card(app_form_section(title, body)) } +pub fn app_stack_v(gap_px: f32) -> Div { + div().flex().flex_col().gap(px(gap_px)) +} + +pub fn app_stack_h(gap_px: f32) -> Div { + div().flex().items_center().gap(px(gap_px)) +} + +pub fn app_cluster(gap_px: f32) -> Div { + div().flex().flex_wrap().items_center().gap(px(gap_px)) +} + +pub fn app_split_shell(sidebar: impl IntoElement, main_content: impl IntoElement) -> AnyElement { + let sidebar = sidebar.into_any_element(); + let main_content = main_content.into_any_element(); + + app_surface_window( + APP_UI_THEME.foundation.surfaces.window_background, + div() + .size_full() + .overflow_hidden() + .flex() + .child(sidebar) + .child( + div() + .h_full() + .w(px(APP_UI_THEME.foundation.borders.divider_thickness_px)) + .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)), + ) + .child( + div() + .flex_1() + .h_full() + .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) + .overflow_hidden() + .child( + div() + .size_full() + .p(px(APP_UI_THEME.shells.home_window_padding_px)) + .child(main_content), + ), + ), + ) + .into_any_element() +} + +pub fn app_scroll_panel( + id: &'static str, + content_padding_px: f32, + content_max_width_px: Option<f32>, + content: impl IntoElement, +) -> AnyElement { + let content = content.into_any_element(); + let content: AnyElement = match content_max_width_px { + Some(content_max_width_px) => div() + .w_full() + .max_w(px(content_max_width_px)) + .mx_auto() + .child(content) + .into_any_element(), + None => div().w_full().child(content).into_any_element(), + }; + + div() + .id(id) + .size_full() + .overflow_y_scroll() + .child( + div() + .w_full() + .when(content_padding_px > 0.0, |this| { + this.p(px(content_padding_px)) + }) + .child(content), + ) + .into_any_element() +} + pub fn app_divider() -> impl IntoElement { div() .w_full()