From b724ab70032022d4ec14db59dcd7a9b80d110dc1 Mon Sep 17 00:00:00 2001 From: Suyog Tandel Date: Wed, 4 Feb 2026 23:58:54 +0530 Subject: [PATCH] chore(ui): convert buttons into a reusable component from entity --- src/ui/components/button.rs | 218 +++++++++++++++++++++-------------- src/ui/components/sidebar.rs | 57 ++++++--- src/ui/rootview.rs | 29 +---- src/ui/views/passkeys.rs | 57 ++++----- 4 files changed, 200 insertions(+), 161 deletions(-) diff --git a/src/ui/components/button.rs b/src/ui/components/button.rs index 3548b5e..06bb2a7 100644 --- a/src/ui/components/button.rs +++ b/src/ui/components/button.rs @@ -4,23 +4,24 @@ use crate::ui::colors; use gpui::prelude::*; use gpui::*; use gpui_component::{ - Icon, + Disableable, Icon, Sizable, Size, button::{Button, ButtonCustomVariant, ButtonVariants}, h_flex, }; -pub struct Clicked; - +/// A stateless text button wrapper +#[derive(IntoElement)] pub struct PFButton { text: SharedString, - on_click: Option) + 'static>>, - hovered: bool, - hover_t: f32, + on_click: Option>, bg_color_start: Rgba, bg_color_hover: Rgba, bg_color_active: Rgba, width_full: bool, centered: bool, + disabled: bool, + small: bool, + loading: bool, } impl PFButton { @@ -28,24 +29,33 @@ impl PFButton { Self { text: text.into(), on_click: None, - hovered: false, - hover_t: 0.0, bg_color_start: rgb(0x1b1b1d), bg_color_hover: rgb(0x232325), bg_color_active: rgb(colors::zinc::ZINC700), width_full: false, centered: false, + disabled: false, + small: false, + loading: false, } } pub fn on_click( mut self, - handler: impl Fn(&ClickEvent, &mut Window, &mut Context) + 'static, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.on_click = Some(Box::new(handler)); self } + /// Allows overriding the default colors + pub fn with_colors(mut self, start: Rgba, hover: Rgba, active: Rgba) -> Self { + self.bg_color_start = start; + self.bg_color_hover = hover; + self.bg_color_active = active; + self + } + pub fn section_header(mut self) -> Self { self.width_full = true; self.centered = false; @@ -61,34 +71,32 @@ impl PFButton { self.centered = true; self } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn small(mut self) -> Self { + self.small = true; + self + } + + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } } -impl Render for PFButton { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let target_hover = if self.hovered { 1.0 } else { 0.0 }; - if (self.hover_t - target_hover).abs() > 0.01 { - self.hover_t += (target_hover - self.hover_t) * 0.2; - window.request_animation_frame(); - } else { - self.hover_t = target_hover; - } - - let c1 = self.bg_color_start; - let c2 = self.bg_color_hover; - let t = self.hover_t; - let bg_color = Rgba { - r: c1.r + (c2.r - c1.r) * t, - g: c1.g + (c2.g - c1.g) * t, - b: c1.b + (c2.b - c1.b) * t, - a: 1.0, - }; - - let text = self.text.clone(); +// RenderOnce makes this a "Component" not an "Entity" +impl RenderOnce for PFButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let text = self.text; let mut btn = Button::new("pf-btn").custom( ButtonCustomVariant::new(cx) - .color(bg_color.into()) - .hover(bg_color.into()) + .color(self.bg_color_start.into()) + .hover(self.bg_color_hover.into()) .active(self.bg_color_active.into()), ); @@ -96,37 +104,46 @@ impl Render for PFButton { btn = btn.w_full(); } + if self.disabled { + btn = btn.disabled(true); + } + + if self.loading { + btn = btn.loading(true); + } + + if self.small { + btn = btn.with_size(Size::Small); + } + let content = if self.centered { h_flex().justify_center().child(text) } else { h_flex().child(text) }; - let on_click = self.on_click.take(); - if let Some(handler) = on_click { - btn = btn.on_click(cx.listener(move |_, event, window, cx| handler(event, window, cx))); + if let Some(handler) = self.on_click { + // We cast the handler to satisfy GPUI's generic requirements + btn = btn.on_click(move |e, w, c| handler(e, w, c)); } - div() - .child(btn.child(content)) - .id("pf-btn-wrapper") - .on_hover(cx.listener(|this, hovered, _, cx| { - if this.hovered != *hovered { - this.hovered = *hovered; - cx.notify(); - } - })) + btn.child(content) } } +/// A stateless Icon + Text button wrapper +#[derive(IntoElement)] pub struct PFIconButton { icon: Icon, text: SharedString, - hovered: bool, - hover_t: f32, + on_click: Option>, bg_color_start: Rgba, bg_color_hover: Rgba, bg_color_active: Rgba, + disabled: bool, + small: bool, + width_full: bool, + loading: bool, } impl PFIconButton { @@ -134,60 +151,85 @@ impl PFIconButton { Self { icon: icon.into(), text: text.into(), - hovered: false, - hover_t: 0.0, + on_click: None, bg_color_start: rgb(0x1b1b1d), bg_color_hover: rgb(0x232325), bg_color_active: rgb(colors::zinc::ZINC700), + disabled: false, + small: false, + width_full: false, + loading: false, } } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } + + pub fn with_colors(mut self, start: Rgba, hover: Rgba, active: Rgba) -> Self { + self.bg_color_start = start; + self.bg_color_hover = hover; + self.bg_color_active = active; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn small(mut self) -> Self { + self.small = true; + self + } + + pub fn w_full(mut self) -> Self { + self.width_full = true; + self + } + + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } } -impl EventEmitter for PFIconButton {} +impl RenderOnce for PFIconButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let text = self.text; + let icon = self.icon; -impl Render for PFIconButton { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let target_hover = if self.hovered { 1.0 } else { 0.0 }; - if (self.hover_t - target_hover).abs() > 0.01 { - self.hover_t += (target_hover - self.hover_t) * 0.2; - window.request_animation_frame(); - } else { - self.hover_t = target_hover; + let mut btn = Button::new("pf-icon-btn").custom( + ButtonCustomVariant::new(cx) + .color(self.bg_color_start.into()) + .hover(self.bg_color_hover.into()) + .active(self.bg_color_active.into()), + ); + + if self.width_full { + btn = btn.w_full(); } - let c1 = self.bg_color_start; - let c2 = self.bg_color_hover; - let t = self.hover_t; - let bg_color = Rgba { - r: c1.r + (c2.r - c1.r) * t, - g: c1.g + (c2.g - c1.g) * t, - b: c1.b + (c2.b - c1.b) * t, - a: 1.0, - }; + if self.disabled { + btn = btn.disabled(true); + } - let text = self.text.clone(); - let icon = self.icon.clone(); + if self.loading { + btn = btn.loading(true); + } - let btn = Button::new("pf-icon-btn") - .custom( - ButtonCustomVariant::new(cx) - .color(bg_color.into()) - .hover(bg_color.into()) - .active(self.bg_color_active.into()), - ) - .w_full() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(Clicked); - })); + if self.small { + btn = btn.with_size(Size::Small); + } - div() - .child(btn.child(h_flex().gap_2().justify_center().child(icon).child(text))) - .id("pf-icon-btn-wrapper") - .on_hover(cx.listener(|this, hovered, _, cx| { - if this.hovered != *hovered { - this.hovered = *hovered; - cx.notify(); - } - })) + if let Some(handler) = self.on_click { + btn = btn.on_click(move |e, w, c| handler(e, w, c)); + } + + btn.child(h_flex().gap_2().justify_center().child(icon).child(text)) } } diff --git a/src/ui/components/sidebar.rs b/src/ui/components/sidebar.rs index edca902..f32158d 100644 --- a/src/ui/components/sidebar.rs +++ b/src/ui/components/sidebar.rs @@ -1,8 +1,15 @@ use crate::device::types::DeviceMethod; use crate::ui::colors; +use crate::ui::components::button::PFIconButton; use crate::ui::ui_types::{ActiveView, GlobalDeviceState}; use gpui::*; -use gpui_component::{ActiveTheme, Icon, IconName, Side, h_flex, sidebar::*, v_flex}; +use gpui_component::{ + ActiveTheme, Icon, IconName, Side, + button::{Button, ButtonVariants}, + h_flex, + sidebar::*, + v_flex, +}; use std::rc::Rc; pub struct AppSidebar { @@ -11,8 +18,7 @@ pub struct AppSidebar { collapsed: bool, state: GlobalDeviceState, on_select: Option)>>, - refresh_btn: Option, - refresh_btn_collapsed: Option, + on_refresh: Option)>>, } impl AppSidebar { @@ -28,8 +34,7 @@ impl AppSidebar { collapsed, state, on_select: None, - refresh_btn: None, - refresh_btn_collapsed: None, + on_refresh: None, } } @@ -41,13 +46,11 @@ impl AppSidebar { self } - pub fn with_refresh_btn(mut self, btn: impl IntoElement) -> Self { - self.refresh_btn = Some(btn.into_any_element()); - self - } - - pub fn with_refresh_btn_collapsed(mut self, btn: impl IntoElement) -> Self { - self.refresh_btn_collapsed = Some(btn.into_any_element()); + pub fn on_refresh( + mut self, + handler: impl Fn(&mut V, &mut Window, &mut Context) + 'static, + ) -> Self { + self.on_refresh = Some(Rc::new(handler)); self } @@ -59,6 +62,10 @@ impl AppSidebar { let border_color = cx.theme().sidebar_border; let muted_foreground = cx.theme().muted_foreground; + // Clone for closures + let on_refresh = self.on_refresh.clone(); + let on_refresh_collapsed = self.on_refresh.clone(); + v_flex() .h_full() .bg(rgb(colors::zinc::ZINC900)) @@ -176,7 +183,17 @@ impl AppSidebar { .items_center() .justify_center() .gap_2() - .children(self.refresh_btn_collapsed) + .child( + Button::new("refresh-btn-collapsed") + .ghost() + .child(Icon::default().path("icons/refresh-cw.svg")) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(f) = &on_refresh_collapsed { + f(this, window, cx); + } + })) + .w_full(), + ) .child(div().w(px(8.)).h(px(8.)).rounded_full().bg( if let Some(status) = &state.device_status { if status.method == DeviceMethod::Fido { @@ -235,7 +252,19 @@ impl AppSidebar { ) }), ) - .children(self.refresh_btn) + .child( + PFIconButton::new( + Icon::default().path("icons/refresh-cw.svg"), + "Refresh", + ) + .on_click(cx.listener( + move |this, _, window, cx| { + if let Some(f) = &on_refresh { + f(this, window, cx); + } + }, + )), + ) }), ) } diff --git a/src/ui/rootview.rs b/src/ui/rootview.rs index d5c861e..bd6b564 100644 --- a/src/ui/rootview.rs +++ b/src/ui/rootview.rs @@ -1,5 +1,4 @@ use crate::device::io; -use crate::ui::components::button::PFIconButton; use crate::ui::components::sidebar::AppSidebar; use crate::ui::ui_types::{ActiveView, GlobalDeviceState}; use crate::ui::{ @@ -13,7 +12,7 @@ use crate::ui::{ use gpui::prelude::*; use gpui::*; use gpui_component::{ - ActiveTheme, Icon, IconName, TitleBar, WindowExt, + ActiveTheme, IconName, TitleBar, WindowExt, button::{Button, ButtonVariants}, h_flex, scroll::ScrollableElement, @@ -26,23 +25,13 @@ pub struct ApplicationRoot { state: GlobalDeviceState, device_loading: bool, sidebar_width: Pixels, - refresh_button: Entity, config_view: Option>, passkeys_view: Option>, } impl ApplicationRoot { pub fn new(cx: &mut Context) -> Self { - let refresh_button = cx - .new(|_cx| PFIconButton::new(Icon::default().path("icons/refresh-cw.svg"), "Refresh")); - - cx.subscribe( - &refresh_button, - |this, _, _: &crate::ui::components::button::Clicked, cx| { - this.refresh_device_status(cx); - }, - ) - .detach(); + // We no longer need to create a persistent PFIconButton entity here let mut this = Self { active_view: ActiveView::Home, @@ -50,7 +39,6 @@ impl ApplicationRoot { state: GlobalDeviceState::new(), device_loading: false, sidebar_width: px(255.), - refresh_button, config_view: None, passkeys_view: None, }; @@ -129,15 +117,10 @@ impl Render for ApplicationRoot { .on_select(|this: &mut Self, view, _, _| { this.active_view = view; }) - .with_refresh_btn(self.refresh_button.clone()) - .with_refresh_btn_collapsed( - Button::new("refresh-btn-collapsed") - .ghost() - .child(Icon::default().path("icons/refresh-cw.svg")) - .on_click(cx.listener(|this, _, _, cx| { - this.refresh_device_status(cx); - })), - ) + // Here is the new connection: passing the refresh logic to the sidebar + .on_refresh(|this, _, cx| { + this.refresh_device_status(cx); + }) .render(cx), ) .child( diff --git a/src/ui/views/passkeys.rs b/src/ui/views/passkeys.rs index 065540b..32fbc27 100644 --- a/src/ui/views/passkeys.rs +++ b/src/ui/views/passkeys.rs @@ -1,6 +1,6 @@ use crate::device::io; use crate::device::types::{FidoDeviceInfo, FullDeviceStatus, StoredCredential}; -use crate::ui::components::{card::Card, page_view::PageView}; +use crate::ui::components::{card::Card, page_view::PageView, button::{PFButton, PFIconButton}}; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{ @@ -275,9 +275,7 @@ impl PasskeysView { ), ) .child( - Button::new("change-pin-btn") - .outline() - .child(if pin_set { "Change PIN" } else { "Set PIN" }) + PFButton::new(if pin_set { "Change PIN" } else { "Set PIN" }) .on_click(listener), ) } @@ -315,10 +313,8 @@ impl PasskeysView { ), ) .child( - Button::new("update-pin-len-btn") - .outline() - .disabled(!pin_set) - .child("Update Minimum Length"), + PFButton::new("Update Minimum Length") + .disabled(!pin_set), ) } @@ -369,14 +365,11 @@ impl PasskeysView { ), ) .child( - Button::new("unlock-btn") - .child( - h_flex() - .gap_2() - .child(Icon::default().path("icons/lock-open.svg")) - .child("Unlock Storage"), - ) - .on_click(listener), + PFIconButton::new( + Icon::default().path("icons/lock-open.svg"), + "Unlock Storage", + ) + .on_click(listener), ), ) } @@ -444,16 +437,12 @@ impl PasskeysView { ), ) .child( - Button::new("lock-storage-btn") - .outline() - .small() - .child( - h_flex() - .gap_2() - .child(Icon::default().path("icons/lock.svg").size_3p5()) - .child("Lock Storage"), - ) - .on_click(lock_listener), + PFIconButton::new( + Icon::default().path("icons/lock.svg").size_3p5(), + "Lock Storage", + ) + .small() + .on_click(lock_listener), ), ) .child(if self.credentials.is_empty() { @@ -517,17 +506,15 @@ impl PasskeysView { .gap_2() .justify_end() .child( - Button::new("cancel-unlock") - .label("Cancel") + PFButton::new("Cancel") .on_click(cx.listener(|this, _, _, cx| { this.active_modal = None; cx.notify(); })) ) .child( - Button::new("confirm-unlock") - .primary() - .label("Unlock") + PFButton::new("Unlock") + // Primary/Zinc style is default .on_click(cx.listener(move |this, _, _, cx| { let pin = pin_input_clone.read(cx).text().to_string(); if !pin.is_empty() { @@ -552,17 +539,15 @@ impl PasskeysView { .gap_2() .justify_end() .child( - Button::new("cancel-del") - .label("Cancel") + PFButton::new("Cancel") .on_click(cx.listener(|this, _, _, cx| { this.active_modal = None; cx.notify(); })) ) .child( - Button::new("confirm-del") - .danger() - .label("Delete") + PFButton::new("Delete") + .with_colors(rgb(0x7f1d1d), rgb(0x991b1b), rgb(0xfca5a5)) // Danger colors .on_click(cx.listener(move |this, _, _, cx| { this.execute_delete(cred_id.clone(), pin_str.clone(), cx); this.active_modal = None;