diff --git a/apps/desktop/src/components/badge.rs b/apps/desktop/src/components/badge.rs new file mode 100644 index 00000000..fde5c8f1 --- /dev/null +++ b/apps/desktop/src/components/badge.rs @@ -0,0 +1,69 @@ +use gpui::{ + App, FontWeight, IntoElement, RenderOnce, SharedString, Window, div, prelude::*, px, + transparent_black, +}; + +use crate::theme::ActiveTheme; + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum BadgeVariant { + #[default] + Default, + Secondary, + Outline, + Destructive, +} + +#[derive(IntoElement)] +pub(crate) struct Badge { + label: SharedString, + variant: BadgeVariant, +} + +impl Badge { + pub(crate) fn new(label: impl Into) -> Self { + Self { + label: label.into(), + variant: BadgeVariant::default(), + } + } + + pub(crate) fn variant(mut self, variant: BadgeVariant) -> Self { + self.variant = variant; + self + } +} + +impl RenderOnce for Badge { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let colors = window.theme().colors; + let transparent = transparent_black(); + let (background, foreground, border) = match self.variant { + BadgeVariant::Default => (colors.primary, colors.primary_foreground, transparent), + BadgeVariant::Secondary => (colors.secondary, colors.secondary_foreground, transparent), + BadgeVariant::Outline => (transparent, colors.foreground, colors.border), + BadgeVariant::Destructive => ( + colors.destructive.opacity(0.1), + colors.destructive, + transparent, + ), + }; + + div() + .flex() + .flex_none() + .items_center() + .h(px(20.0)) + .px(px(6.0)) + .rounded_full() + .border_1() + .border_color(border) + .bg(background) + .text_color(foreground) + .text_size(px(10.0)) + .font_weight(FontWeight::MEDIUM) + .whitespace_nowrap() + .child(self.label) + } +} diff --git a/apps/desktop/src/components/button.rs b/apps/desktop/src/components/button.rs new file mode 100644 index 00000000..b08a3a72 --- /dev/null +++ b/apps/desktop/src/components/button.rs @@ -0,0 +1,217 @@ +use gpui::{ + App, ClickEvent, ElementId, FontWeight, IntoElement, MouseButton, RenderOnce, SharedString, + Window, div, prelude::*, px, transparent_black, +}; + +use crate::theme::ActiveTheme; + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ButtonVariant { + #[default] + Default, + Outline, + Secondary, + Ghost, + Destructive, + Link, +} +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ButtonSize { + #[default] + Default, + XSmall, + Small, + Large, + Icon, + IconXSmall, + IconSmall, + IconLarge, +} + +impl ButtonSize { + fn height(self) -> f32 { + match self { + Self::XSmall | Self::IconXSmall => 20.0, + Self::Small | Self::IconSmall => 24.0, + Self::Default | Self::Icon => 28.0, + Self::Large | Self::IconLarge => 32.0, + } + } + + fn horizontal_padding(self) -> f32 { + match self { + Self::XSmall | Self::Small | Self::Default => 8.0, + Self::Large => 10.0, + Self::Icon | Self::IconXSmall | Self::IconSmall | Self::IconLarge => 0.0, + } + } + + fn is_icon(self) -> bool { + matches!( + self, + Self::Icon | Self::IconXSmall | Self::IconSmall | Self::IconLarge + ) + } +} + +type ClickHandler = Box; + +#[derive(IntoElement)] +pub(crate) struct Button { + id: ElementId, + label: SharedString, + variant: ButtonVariant, + size: ButtonSize, + disabled: bool, + full_width: bool, + on_click: Option, +} + +impl Button { + pub(crate) fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + variant: ButtonVariant::default(), + size: ButtonSize::default(), + disabled: false, + full_width: false, + on_click: None, + } + } + + pub(crate) fn variant(mut self, variant: ButtonVariant) -> Self { + self.variant = variant; + self + } + + pub(crate) fn size(mut self, size: ButtonSize) -> Self { + self.size = size; + self + } + + #[allow(dead_code)] + pub(crate) fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + #[allow(dead_code)] + pub(crate) fn full_width(mut self) -> Self { + self.full_width = true; + self + } + + pub(crate) fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for Button { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let theme = window.theme(); + let colors = theme.colors; + let transparent = transparent_black(); + + let (background, foreground, border, hover, active) = match self.variant { + ButtonVariant::Default => ( + colors.primary, + colors.primary_foreground, + transparent, + colors.primary.opacity(0.8), + colors.primary.opacity(0.7), + ), + ButtonVariant::Outline => ( + transparent, + colors.foreground, + colors.border, + colors.input.opacity(0.5), + colors.input, + ), + ButtonVariant::Secondary => ( + colors.secondary, + colors.secondary_foreground, + transparent, + colors.secondary.opacity(0.8), + colors.secondary.opacity(0.65), + ), + ButtonVariant::Ghost => ( + transparent, + colors.foreground, + transparent, + colors.muted, + colors.muted.opacity(0.75), + ), + ButtonVariant::Destructive => ( + colors.destructive.opacity(0.1), + colors.destructive, + transparent, + colors.destructive.opacity(0.2), + colors.destructive.opacity(0.3), + ), + ButtonVariant::Link => ( + transparent, + colors.primary, + transparent, + transparent, + transparent, + ), + }; + + let height = px(self.size.height()); + let mut button = div() + .id(self.id) + .flex() + .flex_none() + .items_center() + .justify_center() + .h(height) + .when(self.size.is_icon(), |this| this.w(height)) + .when(!self.size.is_icon(), |this| { + this.px(px(self.size.horizontal_padding())) + }) + .when(self.full_width, |this| this.w_full()) + .rounded(theme.radius) + .border_1() + .border_color(border) + .bg(background) + .text_color(foreground) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .whitespace_nowrap() + .tab_index(0) + .focus(|style| style.border_color(colors.ring)) + .child(self.label); + + if self.disabled { + button = button.opacity(0.5).cursor_not_allowed(); + } else { + button = button + .cursor_pointer() + .hover(move |style| { + let style = style.bg(hover); + if self.variant == ButtonVariant::Link { + style.underline() + } else { + style + } + }) + .active(move |style| style.bg(active)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(self.on_click, |this, handler| { + this.on_click(move |event, window, cx| { + cx.stop_propagation(); + handler(event, window, cx); + }) + }); + } + + button + } +} diff --git a/apps/desktop/src/components/context_menu.rs b/apps/desktop/src/components/context_menu.rs new file mode 100644 index 00000000..91caadc7 --- /dev/null +++ b/apps/desktop/src/components/context_menu.rs @@ -0,0 +1,276 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, Entity, IntoElement, MouseButton, Pixels, Point, Render, + SharedString, Window, anchored, deferred, div, prelude::*, px, +}; + +use crate::theme::ActiveTheme; + +type SelectHandler = Rc; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ContextMenuItemVariant { + #[default] + Default, + Destructive, +} +pub(crate) enum ContextMenuItem { + Entry { + label: SharedString, + shortcut: Option, + checked: Option, + disabled: bool, + variant: ContextMenuItemVariant, + on_select: SelectHandler, + }, + Label(SharedString), + Separator, +} + +impl ContextMenuItem { + pub(crate) fn entry(label: impl Into) -> Self { + Self::Entry { + label: label.into(), + shortcut: None, + checked: None, + disabled: false, + variant: ContextMenuItemVariant::Default, + on_select: Rc::new(|_, _| {}), + } + } + + pub(crate) fn label(label: impl Into) -> Self { + Self::Label(label.into()) + } + + pub(crate) fn separator() -> Self { + Self::Separator + } + + pub(crate) fn shortcut(mut self, shortcut: impl Into) -> Self { + if let Self::Entry { + shortcut: item_shortcut, + .. + } = &mut self + { + *item_shortcut = Some(shortcut.into()); + } + self + } + + pub(crate) fn checked(mut self, checked: bool) -> Self { + if let Self::Entry { + checked: item_checked, + .. + } = &mut self + { + *item_checked = Some(checked); + } + self + } + + pub(crate) fn disabled(mut self, disabled: bool) -> Self { + if let Self::Entry { + disabled: item_disabled, + .. + } = &mut self + { + *item_disabled = disabled; + } + self + } + + pub(crate) fn variant(mut self, variant: ContextMenuItemVariant) -> Self { + if let Self::Entry { + variant: item_variant, + .. + } = &mut self + { + *item_variant = variant; + } + self + } + + pub(crate) fn on_select(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + if let Self::Entry { + on_select: item_handler, + .. + } = &mut self + { + *item_handler = Rc::new(handler); + } + self + } +} + +pub(crate) struct ContextMenu { + items: Vec, + position: Option>, +} + +impl ContextMenu { + pub(crate) fn new(items: impl IntoIterator) -> Self { + Self { + items: items.into_iter().collect(), + position: None, + } + } + + fn open(&mut self, position: Point, cx: &mut gpui::Context) { + self.position = Some(position); + cx.notify(); + } + + fn close(&mut self, cx: &mut gpui::Context) { + self.position = None; + cx.notify(); + } + + fn activate(&mut self, index: usize, window: &mut Window, cx: &mut gpui::Context) { + let Some(ContextMenuItem::Entry { + disabled, + on_select, + .. + }) = self.items.get(index) + else { + return; + }; + if *disabled { + return; + } + + let on_select = Rc::clone(on_select); + self.close(cx); + on_select(window, cx); + } +} + +impl Render for ContextMenu { + fn render(&mut self, window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + let Some(position) = self.position else { + return div().into_any_element(); + }; + + let theme = window.theme(); + let colors = theme.colors; + let items = self + .items + .iter() + .enumerate() + .map(|(index, item)| -> AnyElement { + match item { + ContextMenuItem::Entry { + label, + shortcut, + checked, + disabled, + variant, + .. + } => { + let foreground = if *variant == ContextMenuItemVariant::Destructive { + colors.destructive + } else { + colors.popover_foreground + }; + let hover = if *variant == ContextMenuItemVariant::Destructive { + colors.destructive.opacity(0.1) + } else { + colors.accent + }; + + div() + .id(("context-menu-item", index)) + .flex() + .items_center() + .gap(px(8.0)) + .min_h(px(28.0)) + .px(px(8.0)) + .py(px(4.0)) + .rounded(px(5.0)) + .text_xs() + .text_color(foreground) + .when(*disabled, |this| this.opacity(0.5).cursor_not_allowed()) + .when(!*disabled, |this| { + this.cursor_default() + .hover(move |style| style.bg(hover)) + .on_click(cx.listener(move |this, _, window, cx| { + this.activate(index, window, cx); + cx.stop_propagation(); + })) + }) + .child( + div() + .w(px(12.0)) + .text_center() + .child(if checked == &Some(true) { "✓" } else { "" }), + ) + .child(div().flex_1().child(label.clone())) + .when_some(shortcut.clone(), |this, shortcut| { + this.child( + div() + .text_size(px(10.0)) + .text_color(colors.muted_foreground) + .child(shortcut), + ) + }) + .into_any_element() + } + ContextMenuItem::Label(label) => div() + .px(px(8.0)) + .py(px(6.0)) + .text_xs() + .text_color(colors.muted_foreground) + .child(label.clone()) + .into_any_element(), + ContextMenuItem::Separator => div() + .h(px(1.0)) + .mx(px(-4.0)) + .my(px(4.0)) + .bg(colors.border.opacity(0.5)) + .into_any_element(), + } + }) + .collect::>(); + + deferred( + anchored() + .position(position) + .snap_to_window_with_margin(px(8.0)) + .child( + div() + .id("context-menu-content") + .occlude() + .min_w(px(160.0)) + .max_h(window.viewport_size().height - px(16.0)) + .overflow_y_scroll() + .p(px(4.0)) + .rounded(theme.radius) + .bg(colors.popover) + .text_color(colors.popover_foreground) + .border_1() + .border_color(colors.foreground.opacity(0.1)) + .shadow_md() + .on_mouse_down_out(cx.listener(|this, _, _, cx| this.close(cx))) + .children(items), + ), + ) + .with_priority(1) + .into_any_element() + } +} + +pub(crate) fn context_menu_trigger( + id: impl Into, + child: impl IntoElement, + menu: Entity, +) -> impl IntoElement { + div().id(id).size_full().child(child).on_mouse_down( + MouseButton::Right, + move |event, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + menu.update(cx, |menu, cx| menu.open(event.position, cx)); + }, + ) +} diff --git a/apps/desktop/src/components/label.rs b/apps/desktop/src/components/label.rs new file mode 100644 index 00000000..1150f82e --- /dev/null +++ b/apps/desktop/src/components/label.rs @@ -0,0 +1,48 @@ +use gpui::{App, FontWeight, IntoElement, RenderOnce, SharedString, Window, div, prelude::*}; + +use crate::theme::ActiveTheme; + +#[derive(IntoElement)] +pub(crate) struct Label { + text: SharedString, + muted: bool, + disabled: bool, +} + +impl Label { + pub(crate) fn new(text: impl Into) -> Self { + Self { + text: text.into(), + muted: false, + disabled: false, + } + } + + pub(crate) fn muted(mut self) -> Self { + self.muted = true; + self + } + + #[allow(dead_code)] + pub(crate) fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl RenderOnce for Label { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let colors = window.theme().colors; + + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(if self.muted { + colors.muted_foreground + } else { + colors.foreground + }) + .when(self.disabled, |this| this.opacity(0.5)) + .child(self.text) + } +} diff --git a/apps/desktop/src/components/mod.rs b/apps/desktop/src/components/mod.rs new file mode 100644 index 00000000..17a9aa15 --- /dev/null +++ b/apps/desktop/src/components/mod.rs @@ -0,0 +1,15 @@ +mod badge; +mod button; +mod context_menu; +mod label; +mod resizable; +mod separator; + +pub(crate) use badge::{Badge, BadgeVariant}; +pub(crate) use button::{Button, ButtonSize, ButtonVariant}; +pub(crate) use context_menu::{ + ContextMenu, ContextMenuItem, ContextMenuItemVariant, context_menu_trigger, +}; +pub(crate) use label::Label; +pub(crate) use resizable::{Orientation, ResizablePanelGroup}; +pub(crate) use separator::Separator; diff --git a/apps/desktop/src/components/resizable.rs b/apps/desktop/src/components/resizable.rs new file mode 100644 index 00000000..0d163418 --- /dev/null +++ b/apps/desktop/src/components/resizable.rs @@ -0,0 +1,170 @@ +use gpui::{ + AnyView, AppContext, ClickEvent, Context, DragMoveEvent, Entity, IntoElement, Render, Window, + div, prelude::*, px, relative, +}; + +use crate::theme::ActiveTheme; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum Orientation { + #[default] + Horizontal, + Vertical, +} + +#[derive(Debug)] +struct ResizeDrag; + +fn clamp_fraction(fraction: f32, minimum_fraction: f32) -> f32 { + fraction.clamp(minimum_fraction, 1.0 - minimum_fraction) +} + +pub(crate) struct ResizablePanelGroup { + orientation: Orientation, + first: AnyView, + second: AnyView, + initial_fraction: f32, + fraction: f32, + minimum_fraction: f32, +} + +impl ResizablePanelGroup { + pub(crate) fn new(orientation: Orientation, first: Entity, second: Entity) -> Self + where + A: Render, + B: Render, + { + Self { + orientation, + first: first.into(), + second: second.into(), + initial_fraction: 0.5, + fraction: 0.5, + minimum_fraction: 0.1, + } + } + + pub(crate) fn initial_fraction(mut self, fraction: f32) -> Self { + let fraction = self.clamp_fraction(fraction); + self.initial_fraction = fraction; + self.fraction = fraction; + self + } + + pub(crate) fn minimum_fraction(mut self, fraction: f32) -> Self { + self.minimum_fraction = fraction.clamp(0.0, 0.49); + self.initial_fraction = self.clamp_fraction(self.initial_fraction); + self.fraction = self.clamp_fraction(self.fraction); + self + } + + fn clamp_fraction(&self, fraction: f32) -> f32 { + clamp_fraction(fraction, self.minimum_fraction) + } + + fn resize( + &mut self, + event: &DragMoveEvent, + _window: &mut Window, + cx: &mut Context, + ) { + let bounds = event.bounds; + let fraction = match self.orientation { + Orientation::Horizontal => { + (event.event.position.x - bounds.left()) / (bounds.right() - bounds.left()) + } + Orientation::Vertical => { + (event.event.position.y - bounds.top()) / (bounds.bottom() - bounds.top()) + } + }; + + self.fraction = self.clamp_fraction(fraction); + cx.notify(); + } + + fn reset(&mut self, cx: &mut Context) { + self.fraction = self.initial_fraction; + cx.notify(); + } +} + +impl Render for ResizablePanelGroup { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = window.theme().colors; + let orientation = self.orientation; + let first_fraction = self.fraction; + + let handle = div() + .id("resizable-handle") + .flex() + .flex_none() + .items_center() + .justify_center() + .when(orientation == Orientation::Horizontal, |this| { + this.w(px(5.0)).h_full().cursor_col_resize().child( + div() + .w(px(1.0)) + .h_full() + .bg(colors.border) + .group_hover("resizable", |style| style.bg(colors.ring)), + ) + }) + .when(orientation == Orientation::Vertical, |this| { + this.h(px(5.0)).w_full().cursor_row_resize().child( + div() + .h(px(1.0)) + .w_full() + .bg(colors.border) + .group_hover("resizable", |style| style.bg(colors.ring)), + ) + }) + .group("resizable") + .on_click(cx.listener(|this, event: &ClickEvent, _, cx| { + if event.click_count() >= 2 { + this.reset(cx); + } + cx.stop_propagation(); + })) + .on_drag(ResizeDrag, |_, _, _, cx| cx.new(|_| gpui::Empty)); + + div() + .id("resizable-panel-group") + .flex() + .size_full() + .overflow_hidden() + .when(orientation == Orientation::Vertical, |this| this.flex_col()) + .on_drag_move::(cx.listener(Self::resize)) + .child( + div() + .flex_none() + .overflow_hidden() + .when(orientation == Orientation::Horizontal, |this| { + this.w(relative(first_fraction)).h_full() + }) + .when(orientation == Orientation::Vertical, |this| { + this.h(relative(first_fraction)).w_full() + }) + .child(self.first.clone()), + ) + .child(handle) + .child( + div() + .flex_1() + .min_w_0() + .min_h_0() + .child(self.second.clone()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::clamp_fraction; + + #[test] + fn panel_fraction_respects_both_minimums() { + assert_eq!(clamp_fraction(-1.0, 0.2), 0.2); + assert_eq!(clamp_fraction(0.45, 0.2), 0.45); + assert_eq!(clamp_fraction(2.0, 0.2), 0.8); + } +} diff --git a/apps/desktop/src/components/separator.rs b/apps/desktop/src/components/separator.rs new file mode 100644 index 00000000..419f1964 --- /dev/null +++ b/apps/desktop/src/components/separator.rs @@ -0,0 +1,37 @@ +use gpui::{App, IntoElement, RenderOnce, Window, div, prelude::*, px}; + +use crate::{components::Orientation, theme::ActiveTheme}; + +#[derive(IntoElement)] +pub(crate) struct Separator { + orientation: Orientation, +} + +impl Separator { + pub(crate) fn horizontal() -> Self { + Self { + orientation: Orientation::Horizontal, + } + } + + #[allow(dead_code)] + pub(crate) fn vertical() -> Self { + Self { + orientation: Orientation::Vertical, + } + } +} + +impl RenderOnce for Separator { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let color = window.theme().colors.border.opacity(0.5); + + div() + .flex_none() + .bg(color) + .map(|this| match self.orientation { + Orientation::Horizontal => this.w_full().h(px(1.0)), + Orientation::Vertical => this.h_full().w(px(1.0)), + }) + } +} diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 37d40468..7d3e6167 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -3,6 +3,7 @@ use gpui::{ WindowOptions, }; +mod components; mod panels; mod shell; mod theme;