commit 5f8eb389540f03902543101e3be5ae2a2f1d802b
parent d7a796fa2a9323e3c963531770a00274ba3ee5e7
Author: triesap <tyson@radroots.org>
Date: Fri, 17 Apr 2026 18:17:38 +0000
ui: add reusable radroots_app control primitives
- add shared radroots_app segmented navigation, action button, icon button, checkbox field, and status indicator primitives in the ui crate
- drive the new control behavior from the centralized theme contract so later shell slices can reuse one control surface
- export the control specs and render helpers through radroots_app_ui instead of rebuilding interactive chrome in launcher code
- verify cargo test -p radroots_app_ui and cargo check --manifest-path Cargo.toml on the mounted app workspace
Diffstat:
2 files changed, 349 insertions(+), 4 deletions(-)
diff --git a/crates/shared/ui/src/lib.rs b/crates/shared/ui/src/lib.rs
@@ -5,8 +5,10 @@ mod text;
mod theme;
pub use primitives::{
- LabelValueRow, app_card, app_center_stage, app_window_shell, label_value_list, section_divider,
- utility_title_row,
+ AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button,
+ action_button_compact, action_icon_button, app_card, app_center_stage, app_checkbox,
+ app_checkbox_field, app_window_shell, icon_segment_button, label_value_list, section_divider,
+ status_indicator, utility_title_row,
};
pub use text::{
app_shared_text, runtime_metadata_rows, settings_about_build_rows, settings_about_status_rows,
diff --git a/crates/shared/ui/src/primitives.rs b/crates/shared/ui/src/primitives.rs
@@ -1,7 +1,51 @@
-use gpui::{IntoElement, ParentElement, SharedString, Styled, div, px, rgb};
+use gpui::{
+ App, ClickEvent, IntoElement, ParentElement, SharedString, Styled, Window, div,
+ prelude::FluentBuilder, px, relative, rgb, transparent_black,
+};
+use gpui_component::{
+ Icon, IconName, Sizable, Size,
+ button::{Button, ButtonCustomVariant, ButtonRounded, ButtonVariants},
+};
+use std::rc::Rc;
use crate::APP_UI_THEME;
+pub struct IconSegmentButtonSpec {
+ pub id: &'static str,
+ pub label: SharedString,
+ pub icon: IconName,
+}
+
+impl IconSegmentButtonSpec {
+ pub fn new(id: &'static str, label: impl Into<SharedString>, icon: IconName) -> Self {
+ Self {
+ id,
+ label: label.into(),
+ icon,
+ }
+ }
+}
+
+pub struct AppCheckboxFieldSpec {
+ pub id: &'static str,
+ pub label: SharedString,
+ pub note: Option<SharedString>,
+}
+
+impl AppCheckboxFieldSpec {
+ pub fn new(
+ id: &'static str,
+ label: impl Into<SharedString>,
+ note: Option<impl Into<SharedString>>,
+ ) -> Self {
+ Self {
+ id,
+ label: label.into(),
+ note: note.map(Into::into),
+ }
+ }
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LabelValueRow {
pub label: SharedString,
@@ -39,13 +83,15 @@ pub fn app_center_stage(content: impl IntoElement) -> impl IntoElement {
pub fn app_card(content: impl IntoElement) -> impl IntoElement {
div()
.w_full()
+ .h_full()
.max_w(px(APP_UI_THEME.layout.home_card_max_width_px))
.mx_auto()
.bg(rgb(APP_UI_THEME.surfaces.card_background))
.overflow_hidden()
.child(
div()
- .w_full()
+ .size_full()
+ .overflow_hidden()
.p(px(APP_UI_THEME.layout.home_card_padding_px))
.child(content),
)
@@ -91,3 +137,300 @@ pub fn label_value_list(rows: impl IntoIterator<Item = LabelValueRow>) -> impl I
.gap(px(APP_UI_THEME.layout.metadata_row_gap_px))
.children(rows)
}
+
+pub fn app_checkbox(
+ id: &'static str,
+ checked: bool,
+ cx: &App,
+ on_change: impl Fn(bool, &mut Window, &mut App) + 'static,
+) -> impl IntoElement {
+ let colors = APP_UI_THEME.controls.checkbox;
+ let background = if checked {
+ colors.checked_background
+ } else {
+ colors.unchecked_background
+ };
+ let border = if checked {
+ colors.checked_background
+ } else {
+ colors.unchecked_border
+ };
+ let mut button = Button::new(id)
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .color(rgb(background).into())
+ .foreground(rgb(colors.check_foreground).into())
+ .border(rgb(border).into())
+ .hover(rgb(background).into())
+ .active(rgb(background).into()),
+ )
+ .rounded(ButtonRounded::Size(px(colors.corner_radius_px)))
+ .with_size(Size::Size(px(colors.size_px)))
+ .on_click(move |_, window, cx| on_change(!checked, window, cx));
+
+ if checked {
+ button = button.icon(
+ Icon::new(IconName::Check)
+ .with_size(Size::Size(px(colors.icon_size_px)))
+ .text_color(rgb(colors.check_foreground)),
+ );
+ }
+
+ button
+}
+
+pub fn app_checkbox_field(
+ spec: AppCheckboxFieldSpec,
+ checked: bool,
+ cx: &App,
+ on_change: impl Fn(bool, &mut Window, &mut App) + 'static,
+) -> impl IntoElement {
+ let checkbox_id = spec.id;
+ let checkbox_label = spec.label;
+ let checkbox_note = spec.note;
+ let row_text_px = APP_UI_THEME.typography.settings_row_text_px;
+ let note_text_px = APP_UI_THEME.typography.utility_title_text_px;
+ let note_indent_px =
+ APP_UI_THEME.controls.checkbox.size_px + APP_UI_THEME.layout.settings_checkbox_label_gap_px;
+ let on_change = Rc::new(on_change);
+
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(4.0))
+ .child(
+ Button::new((checkbox_id, 0usize))
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .color(transparent_black().into())
+ .foreground(rgb(APP_UI_THEME.text.primary).into())
+ .border(transparent_black())
+ .hover(transparent_black().into())
+ .active(transparent_black().into()),
+ )
+ .rounded(ButtonRounded::Size(px(0.0)))
+ .w_full()
+ .on_click({
+ let on_change = Rc::clone(&on_change);
+ move |_, window, cx| on_change(!checked, window, cx)
+ })
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_start()
+ .gap(px(APP_UI_THEME.layout.settings_checkbox_label_gap_px))
+ .child(app_checkbox(checkbox_id, checked, cx, {
+ let on_change = Rc::clone(&on_change);
+ move |checked, window, cx| on_change(checked, window, cx)
+ }))
+ .child(
+ div()
+ .min_w_0()
+ .text_size(px(row_text_px))
+ .line_height(relative(1.1))
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(checkbox_label),
+ ),
+ ),
+ )
+ .when_some(checkbox_note, |this, note| {
+ this.child(
+ div()
+ .w_full()
+ .pl(px(note_indent_px))
+ .min_w_0()
+ .text_size(px(note_text_px))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(note),
+ )
+ })
+}
+
+pub fn icon_segment_button(
+ spec: IconSegmentButtonSpec,
+ is_active: bool,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ let colors = APP_UI_THEME.controls.icon_segment_button.colors;
+ let sizing = APP_UI_THEME.controls.icon_segment_button.sizing;
+ let background = if is_active {
+ colors.active_background
+ } else {
+ colors.inactive_background
+ };
+ let foreground = if is_active {
+ colors.active_foreground
+ } else {
+ colors.inactive_foreground
+ };
+
+ Button::new(spec.id)
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .color(rgb(background).into())
+ .foreground(rgb(foreground).into())
+ .border(transparent_black())
+ .hover(rgb(background).into())
+ .active(rgb(background).into()),
+ )
+ .rounded(ButtonRounded::Size(px(sizing.corner_radius_px)))
+ .h(px(sizing.height_px))
+ .min_w(px(sizing.height_px))
+ .on_click(on_click)
+ .child(
+ div()
+ .h_full()
+ .flex()
+ .flex_col()
+ .justify_between()
+ .items_center()
+ .px(px(sizing.inner_padding_px))
+ .py(px(sizing.inner_padding_px))
+ .child(
+ Icon::new(spec.icon)
+ .with_size(Size::Size(px(sizing.icon_size_px)))
+ .text_color(rgb(foreground)),
+ )
+ .child(
+ div()
+ .text_size(px(sizing.label_size_px))
+ .text_color(rgb(foreground))
+ .child(spec.label),
+ ),
+ )
+}
+
+pub fn action_button(
+ id: &'static str,
+ label: impl Into<SharedString>,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ action_button_label(
+ action_button_base(id, on_click, cx),
+ label.into(),
+ APP_UI_THEME
+ .controls
+ .action_button
+ .sizing
+ .horizontal_padding_px,
+ )
+}
+
+pub fn action_button_compact(
+ id: &'static str,
+ label: impl Into<SharedString>,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ action_button_label(
+ action_button_base(id, on_click, cx),
+ label.into(),
+ APP_UI_THEME
+ .controls
+ .action_button
+ .sizing
+ .compact_horizontal_padding_px,
+ )
+}
+
+fn action_button_label(
+ button: Button,
+ label: SharedString,
+ horizontal_padding_px: f32,
+) -> impl IntoElement {
+ let sizing = APP_UI_THEME.controls.action_button.sizing;
+ let colors = APP_UI_THEME.controls.action_button.colors;
+ button.child(
+ div()
+ .h_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .px(px(horizontal_padding_px))
+ .text_size(px(sizing.label_size_px))
+ .text_color(rgb(colors.foreground))
+ .child(label),
+ )
+}
+
+pub fn action_icon_button(
+ id: &'static str,
+ icon: IconName,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ let sizing = APP_UI_THEME.controls.action_button.sizing;
+ let colors = APP_UI_THEME.controls.action_button.colors;
+
+ action_button_base(id, on_click, cx)
+ .with_size(Size::Size(px(sizing.square_width_px)))
+ .icon(
+ Icon::new(icon)
+ .with_size(Size::Size(px(sizing.icon_size_px)))
+ .text_color(rgb(colors.foreground)),
+ )
+}
+
+pub fn status_indicator(color: u32) -> impl IntoElement {
+ let sizing = APP_UI_THEME.controls.status_indicator;
+
+ div()
+ .size(px(sizing.size_px))
+ .bg(rgb(color))
+ .rounded(px(sizing.size_px / 2.0))
+}
+
+fn action_button_base(
+ id: &'static str,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> Button {
+ let sizing = APP_UI_THEME.controls.action_button.sizing;
+ let colors = APP_UI_THEME.controls.action_button.colors;
+ let hover_background = if colors.hover_changes_background {
+ colors.hover_background
+ } else {
+ colors.background
+ };
+
+ Button::new(id)
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .color(rgb(colors.background).into())
+ .foreground(rgb(colors.foreground).into())
+ .border(transparent_black())
+ .hover(rgb(hover_background).into())
+ .active(rgb(colors.active_background).into()),
+ )
+ .rounded(ButtonRounded::Size(px(sizing.corner_radius_px)))
+ .h(px(sizing.height_px))
+ .on_click(on_click)
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui_component::IconName;
+
+ use super::{AppCheckboxFieldSpec, IconSegmentButtonSpec};
+
+ #[test]
+ fn icon_segment_spec_preserves_id_and_label() {
+ let spec = IconSegmentButtonSpec::new("settings", "Settings", IconName::Settings2);
+
+ assert_eq!(spec.id, "settings");
+ assert_eq!(spec.label.as_ref(), "Settings");
+ }
+
+ #[test]
+ fn checkbox_field_spec_preserves_optional_note() {
+ let spec = AppCheckboxFieldSpec::new("launch", "Launch at login", Some("Optional note"));
+
+ assert_eq!(spec.id, "launch");
+ assert_eq!(spec.label.as_ref(), "Launch at login");
+ assert_eq!(spec.note.as_ref().map(|note| note.as_ref()), Some("Optional note"));
+ }
+}