chore(ui): abstract away cards into a card component

This commit is contained in:
Suyog Tandel
2026-01-30 21:21:26 +05:30
parent f188197dda
commit c1beeff6b2
7 changed files with 394 additions and 434 deletions
+108
View File
@@ -0,0 +1,108 @@
use gpui::*;
use gpui_component::{ActiveTheme, Icon, Theme, h_flex, v_flex};
#[derive(IntoElement)]
pub struct Card {
title: Option<SharedString>,
description: Option<SharedString>,
icon: Option<Icon>,
header_right: Option<AnyElement>,
children: Vec<AnyElement>,
}
impl Card {
pub fn new() -> Self {
Self {
title: None,
description: None,
icon: None,
header_right: None,
children: Vec::new(),
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
pub fn header_right(mut self, element: impl IntoElement) -> Self {
self.header_right = Some(element.into_any_element());
self
}
}
impl ParentElement for Card {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Card {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme: &Theme = cx.theme();
let has_header = self.title.is_some()
|| self.icon.is_some()
|| self.description.is_some()
|| self.header_right.is_some();
let header = if has_header {
let mut left_side = v_flex().gap_1();
let mut icon_title_row = h_flex().items_center().gap_2();
if let Some(icon) = self.icon {
icon_title_row =
icon_title_row.child(Icon::new(icon).size_5().text_color(theme.foreground));
}
if let Some(title) = self.title {
icon_title_row = icon_title_row.child(
div()
.font_weight(FontWeight::BOLD)
.text_color(theme.foreground)
.child(title),
);
}
left_side = left_side.child(icon_title_row);
if let Some(desc) = self.description {
left_side = left_side.child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.child(desc),
);
}
let mut header_row = h_flex().items_center().justify_between().child(left_side);
if let Some(right) = self.header_right {
header_row = header_row.child(right);
}
Some(header_row.into_any_element())
} else {
None
};
div()
.w_full()
.bg(rgb(0x18181b))
.border_1()
.border_color(theme.border)
.rounded_xl()
.p_6()
.child(v_flex().gap_6().children(header).children(self.children))
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod button;
pub mod card;
pub mod page_view;
pub mod sidebar;
+90 -93
View File
@@ -1,5 +1,5 @@
// src/views/about.rs
use crate::ui::components::page_view::PageView;
use crate::ui::components::{card::Card, page_view::PageView};
use gpui::*;
use gpui_component::{Icon, StyledExt, Theme, badge::Badge, button::Button, h_flex, v_flex};
@@ -16,100 +16,97 @@ impl AboutView {
.justify_center()
.child(
div()
.max_w(px(1200.0))
.w_full()
.bg(rgb(0x18181b))
.border_1()
.border_color(theme.border)
.rounded_xl()
.p_6()
.max_w(px(1200.0))
.child(
v_flex()
.items_center()
.justify_center()
.gap_4()
.py_8()
.text_center()
.child(
img("appIcons/in.suyogtandel.picoforge.svg")
.w(px(256.0))
.h(px(256.0)),
)
.child(
div()
.text_2xl()
.font_bold()
.text_color(theme.foreground)
.child("PicoForge"),
)
.child(Badge::new().child("v0.4.0").color(theme.secondary))
.child(
div()
.text_color(theme.muted_foreground)
.max_w(px(450.0))
.child(
"An open source commissioning tool for Pico FIDO security keys. Developed with Rust and GPUI.",
),
)
.child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.gap_1()
.pt_4()
.border_t_1()
.border_color(theme.border)
.border_t_1()
.border_color(theme.border)
.w(px(320.0))
.child(
h_flex()
.justify_between()
.child("Code By:")
.child(
div()
.font_medium()
.text_color(theme.foreground)
.child("Suyog Tandel, Fabrice Bellamy"),
),
)
.child(
h_flex()
.justify_between()
.items_center()
.pt_2()
.mt_2()
.child(h_flex().items_center().gap_1().child("Copyright:"))
.child(
div()
.font_medium()
.text_color(theme.foreground)
.child("© 2026 Suyog Tandel"),
),
),
)
.child(
h_flex()
.gap_4()
.pt_6()
.child(
Button::new("github_btn")
.outline()
.child(
h_flex()
.gap_2()
.child(
Icon::default()
.path("icons/github.svg")
.size_4(),
)
.child("GitHub"),
)
.on_click(|_, _, cx| {
cx.open_url("https://github.com/librekeys/picoforge")
}),
),
),
Card::new().child(
v_flex()
.items_center()
.justify_center()
.gap_4()
.py_8()
.text_center()
.child(
img("appIcons/in.suyogtandel.picoforge.svg")
.w(px(256.0))
.h(px(256.0)),
)
.child(
div()
.text_2xl()
.font_bold()
.text_color(theme.foreground)
.child("PicoForge"),
)
.child(Badge::new().child("v0.4.0").color(theme.secondary))
.child(
div()
.text_color(theme.muted_foreground)
.max_w(px(450.0))
.child(
"An open source commissioning tool for Pico FIDO security keys. Developed with Rust and GPUI.",
),
)
.child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.gap_1()
.pt_4()
.border_t_1()
.border_color(theme.border)
.border_t_1()
.border_color(theme.border)
.w(px(320.0))
.child(
h_flex()
.justify_between()
.child("Code By:")
.child(
div()
.font_medium()
.text_color(theme.foreground)
.child("Suyog Tandel, Fabrice Bellamy"),
),
)
.child(
h_flex()
.justify_between()
.items_center()
.pt_2()
.mt_2()
.child(h_flex().items_center().gap_1().child("Copyright:"))
.child(
div()
.font_medium()
.text_color(theme.foreground)
.child("© 2026 Suyog Tandel"),
),
),
)
.child(
h_flex()
.gap_4()
.pt_6()
.child(
Button::new("github_btn")
.outline()
.child(
h_flex()
.gap_2()
.child(
Icon::default()
.path("icons/github.svg")
.size_4(),
)
.child("GitHub"),
)
.on_click(|_, _, cx| {
cx.open_url("https://github.com/librekeys/picoforge")
}),
),
),
),
),
),
theme,
+22 -73
View File
@@ -1,10 +1,10 @@
use crate::device::io;
use crate::device::types::{AppConfigInput, FullDeviceStatus};
use crate::ui::components::page_view::PageView;
use crate::ui::components::{card::Card, page_view::PageView};
use crate::ui::ui_types::VENDORS;
use gpui::*;
use gpui_component::{
ActiveTheme, Disableable, Icon, StyledExt, Theme,
ActiveTheme, Disableable, Icon, Theme,
button::Button,
input::{Input, InputState},
select::{Select, SelectItem, SelectState},
@@ -432,13 +432,11 @@ impl ConfigView {
.child(Input::new(&self.product_name_input)),
);
Self::config_card(
"Identity",
"USB Identification settings",
Icon::default().path("icons/tag.svg"),
content,
theme,
)
Card::new()
.title("Identity")
.description("USB Identification settings")
.icon(Icon::default().path("icons/tag.svg"))
.child(content)
}
fn render_led_card(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -522,13 +520,11 @@ impl ConfigView {
),
);
Self::config_card(
"LED Settings",
"Adjust visual feedback behavior",
Icon::default().path("icons/microchip.svg"),
content,
theme,
)
Card::new()
.title("LED Settings")
.description("Adjust visual feedback behavior")
.icon(Icon::default().path("icons/microchip.svg"))
.child(content)
}
fn render_touch_card(&self, theme: &Theme) -> impl IntoElement {
@@ -539,13 +535,11 @@ impl ConfigView {
.child(Input::new(&self.touch_timeout_input)),
);
Self::config_card(
"Touch & Timing",
"Configure interaction timeouts",
Icon::default().path("icons/settings.svg"),
content,
theme,
)
Card::new()
.title("Touch & Timing")
.description("Configure interaction timeouts")
.icon(Icon::default().path("icons/settings.svg"))
.child(content)
}
fn render_options_card(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -600,56 +594,11 @@ impl ConfigView {
),
);
Self::config_card(
"Device Options",
"Toggle advanced features",
Icon::default().path("icons/settings.svg"),
content,
&theme,
)
}
fn config_card(
title: &str,
description: &str,
icon: Icon,
content: impl IntoElement,
theme: &Theme,
) -> impl IntoElement {
div()
.w_full()
.bg(rgb(0x18181b)) // Using the same bg as home card
.border_1()
.border_color(theme.border)
.rounded_xl()
.p_6()
.child(
v_flex()
.gap_6()
.child(
v_flex()
.gap_1()
.child(
gpui_component::h_flex()
.items_center()
.gap_2()
.child(Icon::new(icon).size_5().text_color(theme.foreground))
.child(
div()
.font_bold()
.text_color(theme.foreground)
.child(title.to_string()),
),
)
.child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.child(description.to_string()),
),
)
.child(content),
)
Card::new()
.title("Device Options")
.description("Toggle advanced features")
.icon(Icon::default().path("icons/settings.svg"))
.child(content)
}
}
+150 -187
View File
@@ -1,5 +1,5 @@
use crate::device::types::DeviceMethod;
use crate::ui::components::page_view::PageView;
use crate::ui::components::{card::Card, page_view::PageView};
use crate::ui::ui_types::GlobalDeviceState;
use gpui::*;
use gpui_component::StyledExt;
@@ -46,39 +46,6 @@ impl HomeView {
)
}
fn home_card(
title: &str,
icon: Icon,
content: impl IntoElement,
theme: &Theme,
) -> impl IntoElement {
div()
.w_full()
// TODO: REPLACE with a constant or modify default theme
.bg(rgb(0x18181b))
.border_1()
.border_color(theme.border)
.rounded_xl()
.p_6()
.child(
v_flex()
.gap_6()
.child(
h_flex()
.items_center()
.gap_2()
.child(Icon::new(icon).size_5().text_color(theme.foreground))
.child(
div()
.font_bold()
.text_color(theme.foreground)
.child(title.to_string()),
),
)
.child(content),
)
}
// Helper for Key-Value pairs
fn render_kv(
label: &str,
@@ -115,70 +82,70 @@ impl HomeView {
let flash_percent = (info.flash_used as f32 / info.flash_total as f32) * 100.0;
Self::home_card(
"Device Information",
Icon::default().path("icons/cpu.svg"),
v_flex()
.gap_6()
.child(
div()
.grid()
.grid_cols(2)
.gap_4()
.child(Self::render_kv(
"Serial Number",
info.serial.clone(),
theme,
true,
))
.child(Self::render_kv(
"Firmware Version",
format!("v{}", info.firmware_version),
theme,
true,
))
.child(Self::render_kv(
"VID:PID",
format!("{}, {}", config.vid, config.pid),
theme,
true,
))
.child(Self::render_kv(
"Product Name",
config.product_name.clone(),
theme,
false,
)),
)
.child(div().h_px().bg(theme.border))
.child(
v_flex()
.gap_2()
.child(
h_flex()
.justify_between()
.text_sm()
.child(
div()
.text_color(theme.muted_foreground)
.child("Flash Memory"),
)
.child(div().text_color(theme.foreground).child(format!(
"{:.0} / {:.0} KB",
info.flash_used, info.flash_total
))),
)
.child(Progress::new().value(flash_percent)),
),
theme,
)
Card::new()
.title("Device Information")
.icon(Icon::default().path("icons/cpu.svg"))
.child(
v_flex()
.gap_6()
.child(
div()
.grid()
.grid_cols(2)
.gap_4()
.child(Self::render_kv(
"Serial Number",
info.serial.clone(),
theme,
true,
))
.child(Self::render_kv(
"Firmware Version",
format!("v{}", info.firmware_version),
theme,
true,
))
.child(Self::render_kv(
"VID:PID",
format!("{}, {}", config.vid, config.pid),
theme,
true,
))
.child(Self::render_kv(
"Product Name",
config.product_name.clone(),
theme,
false,
)),
)
.child(div().h_px().bg(theme.border))
.child(
v_flex()
.gap_2()
.child(
h_flex()
.justify_between()
.text_sm()
.child(
div()
.text_color(theme.muted_foreground)
.child("Flash Memory"),
)
.child(div().text_color(theme.foreground).child(format!(
"{:.0} / {:.0} KB",
info.flash_used, info.flash_total
))),
)
.child(Progress::new().value(flash_percent)),
),
)
}
fn render_fido_info(state: &GlobalDeviceState, theme: &Theme) -> impl IntoElement {
Self::home_card(
"FIDO2 Information",
Icon::default().path("icons/shield.svg"),
if let Some(fido) = &state.fido_info {
Card::new()
.title("FIDO2 Information")
.icon(Icon::default().path("icons/shield.svg"))
.child(if let Some(fido) = &state.fido_info {
v_flex()
.gap_6()
.child(
@@ -228,18 +195,16 @@ impl HomeView {
.text_color(theme.muted_foreground)
.child("FIDO information not available")
.into_any_element()
},
theme,
)
})
}
fn render_led_config(state: &GlobalDeviceState, theme: &Theme) -> impl IntoElement {
let status = state.device_status.as_ref().unwrap();
let config = &status.config;
Self::home_card(
"LED Configuration",
Icon::default().path("icons/microchip.svg"),
if status.method == DeviceMethod::Fido {
Card::new()
.title("LED Configuration")
.icon(Icon::default().path("icons/microchip.svg"))
.child(if status.method == DeviceMethod::Fido {
v_flex()
.items_center()
.justify_center()
@@ -328,95 +293,93 @@ impl HomeView {
),
)
.into_any_element()
},
theme,
)
})
}
fn render_security_status(state: &GlobalDeviceState, theme: &Theme) -> impl IntoElement {
let status = state.device_status.as_ref().unwrap();
Self::home_card(
"Security Status",
Icon::default().path("icons/shield-check.svg"),
v_flex()
.gap_3()
.text_sm()
.child(
h_flex()
.justify_between()
.items_center()
.child(div().text_color(theme.muted_foreground).child("Boot Mode"))
.child(
h_flex()
.gap_2()
.items_center()
.child(if status.secure_boot {
Icon::default()
.path("icons/lock.svg")
.size_3p5()
.text_color(gpui::green())
Card::new()
.title("Security Status")
.icon(Icon::default().path("icons/shield-check.svg"))
.child(
v_flex()
.gap_3()
.text_sm()
.child(
h_flex()
.justify_between()
.items_center()
.child(div().text_color(theme.muted_foreground).child("Boot Mode"))
.child(
h_flex()
.gap_2()
.items_center()
.child(if status.secure_boot {
Icon::default()
.path("icons/lock.svg")
.size_3p5()
.text_color(gpui::green())
} else {
Icon::default()
.path("icons/lock-open.svg")
.size_3p5()
.text_color(rgb(0xfe9a00))
})
.child(
Badge::new()
.child(if status.secure_boot {
"Secure Boot"
} else {
"Development"
})
.color(if status.secure_boot {
theme.primary
} else {
theme.secondary
}),
),
),
)
.child(
h_flex()
.justify_between()
.items_center()
.child(
div()
.text_color(theme.muted_foreground)
.child("Debug Interface"),
)
.child(div().font_medium().text_color(theme.foreground).child(
if status.secure_lock {
"Read-out Locked"
} else {
Icon::default()
.path("icons/lock-open.svg")
.size_3p5()
.text_color(rgb(0xfe9a00))
})
.child(
Badge::new()
.child(if status.secure_boot {
"Secure Boot"
} else {
"Development"
})
.color(if status.secure_boot {
theme.primary
} else {
theme.secondary
}),
),
),
)
.child(
h_flex()
.justify_between()
.items_center()
.child(
div()
.text_color(theme.muted_foreground)
.child("Debug Interface"),
)
.child(div().font_medium().text_color(theme.foreground).child(
if status.secure_lock {
"Read-out Locked"
} else {
"Debug Enabled"
},
)),
)
.child(
h_flex()
.justify_between()
.items_center()
.child(
div()
.text_color(theme.muted_foreground)
.child("Secure Lock"),
)
.child(
Badge::new()
.child(if status.secure_lock {
"Acknowledged"
} else {
"Pending"
})
.color(if status.secure_lock {
gpui::red()
} else {
theme.secondary
}),
),
),
theme,
)
"Debug Enabled"
},
)),
)
.child(
h_flex()
.justify_between()
.items_center()
.child(
div()
.text_color(theme.muted_foreground)
.child("Secure Lock"),
)
.child(
Badge::new()
.child(if status.secure_lock {
"Acknowledged"
} else {
"Pending"
})
.color(if status.secure_lock {
gpui::red()
} else {
theme.secondary
}),
),
),
)
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::ui::components::page_view::PageView;
use gpui::*;
use gpui_component::{
ActiveTheme, Icon, StyledExt, button::Button, h_flex, scroll::ScrollableElement, v_flex,
ActiveTheme, Icon, button::Button, h_flex, scroll::ScrollableElement, v_flex,
};
#[derive(Clone, Copy, Debug, PartialEq)]
+22 -80
View File
@@ -1,4 +1,4 @@
use crate::ui::components::page_view::PageView;
use crate::ui::components::{card::Card, page_view::PageView};
use gpui::*;
use gpui_component::StyledExt;
use gpui_component::button::ButtonVariants;
@@ -125,75 +125,19 @@ impl PasskeysView {
.into_any_element()
}
fn main_card(
title: &str,
icon_path: impl Into<SharedString>,
description: &str,
content: impl IntoElement,
header_right: Option<impl IntoElement>,
theme: &Theme,
) -> impl IntoElement {
div()
.w_full()
.bg(rgb(0x18181b)) // Using the same dark bg as in HomeView
.border_1()
.border_color(theme.border)
.rounded_xl()
.p_6()
fn render_pin_management(device: &DeviceState, theme: &Theme) -> impl IntoElement {
Card::new()
.title("PIN Management")
.icon(Icon::default().path("icons/key.svg"))
.description("Configure FIDO2 PIN security")
.child(
v_flex()
.gap_6()
.child(
h_flex()
.items_center()
.justify_between()
.child(
v_flex()
.gap_1()
.child(
h_flex()
.items_center()
.gap_2()
.child(
Icon::default()
.path(icon_path)
.size_5()
.text_color(theme.foreground),
)
.child(
div()
.font_bold()
.text_color(theme.foreground)
.child(title.to_string()),
),
)
.child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.child(description.to_string()),
),
)
.children(header_right),
)
.child(content),
.gap_4()
.child(Self::render_pin_status_row(device, theme))
.child(Self::render_min_pin_length_row(device, theme)),
)
}
fn render_pin_management(device: &DeviceState, theme: &Theme) -> impl IntoElement {
Self::main_card(
"PIN Management",
"icons/key.svg",
"Configure FIDO2 PIN security",
v_flex()
.gap_4()
.child(Self::render_pin_status_row(device, theme))
.child(Self::render_min_pin_length_row(device, theme)),
None::<AnyElement>,
theme,
)
}
fn render_pin_status_row(device: &DeviceState, theme: &Theme) -> impl IntoElement {
let pin_set = device
.fido_info
@@ -263,24 +207,22 @@ impl PasskeysView {
}
fn render_stored_passkeys(device: &DeviceState, theme: &Theme) -> impl IntoElement {
Self::main_card(
"Stored Passkeys",
"icons/key-round.svg",
"View and manage your resident credentials",
if !device.unlocked {
Card::new()
.title("Stored Passkeys")
.icon(Icon::default().path("icons/key-round.svg"))
.description("View and manage your resident credentials")
.child(if !device.unlocked {
Self::render_locked_state(theme).into_any_element()
} else {
Self::render_unlocked_state(device, theme).into_any_element()
},
Some(
div()
.child("View and manage your resident credentials")
.text_sm()
.text_color(theme.muted_foreground)
.invisible(),
),
theme,
)
})
// header_right was effectively invisible/placeholder in original code:
// Some(div().child("View and manage...").invisible())
// We can omit it or add it if needed, but it looked like a hack or mistake in original code?
// The original header_right was:
// Some(div().child("View and manage your resident credentials").text_sm().text_color(...).invisible())
// This suggests it was maybe trying to take up space or something? But description handles the text.
// I will simplify and omit it, which is cleaner.
}
fn render_locked_state(theme: &Theme) -> impl IntoElement {