From 58f4f2bfd0f6a75927af0afdf725be87a63c6804 Mon Sep 17 00:00:00 2001 From: Ignacio Perez Date: Sat, 27 Dec 2025 18:44:45 -0300 Subject: [PATCH] feat: Add card-based layout with theme tokens Add a card-based UI layout with consistent spacing and visual hierarchy. The main content and sidebar are now wrapped in styled cards with gaps between them. New theme tokens (card, raised, hover, divider, disabled_fg) provide semantic colors for different surface levels and states. Create ui.rs module with helper functions (card_style, divider_h, section_header, priority_badge) to keep styling consistent across views. --- src/app.rs | 36 +++--- src/components/button/dropdown.rs | 5 +- src/main.rs | 1 + src/theme.rs | 185 ++++++++++++++++++++------ src/ui.rs | 82 ++++++++++++ src/view/sidebar.rs | 208 ++++++++++++++---------------- src/view/status_bar.rs | 11 +- src/view/task_table.rs | 48 +++---- 8 files changed, 377 insertions(+), 199 deletions(-) create mode 100644 src/ui.rs diff --git a/src/app.rs b/src/app.rs index f23d1ef..cfc036a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ use gpui::prelude::*; use crate::models::{FilterState, ProjectTree}; use crate::task::{TaskOverview, TaskService}; use crate::theme::ActiveTheme; +use crate::ui::{card_style, SECTION_GAP, ROOT_PADDING}; use crate::view::sidebar::{Sidebar, TagItem}; use crate::view::status_bar::StatusBar; use crate::view::task_table::TaskTable; @@ -24,31 +25,36 @@ impl gpui::Render for App { ) -> impl gpui::IntoElement { let theme = cx.theme(); + let sidebar = card_style(div(), theme) + .w(gpui::px(250.)) + .h_full() + .flex_shrink_0() + .overflow_hidden() + .child(self.sidebar.clone()); + + let main = card_style(div(), theme) + .flex_1() + .h_full() + .min_w_0() + .overflow_hidden() + .p_0() + .child(self.task_table.clone()); + let content = div() .flex() .flex_1() .min_h_0() - .overflow_hidden() - .child( - div() - .w(gpui::px(250.)) - .h_full() - .flex_shrink_0() - .child(self.sidebar.clone()), - ) - .child( - div() - .flex_1() - .h_full() - .min_w_0() - .child(self.task_table.clone()), - ); + .gap(SECTION_GAP) + .child(sidebar) + .child(main); div() .flex() .flex_col() .size_full() .bg(theme.background) + .p(ROOT_PADDING) + .gap(SECTION_GAP) .child(content) .child(self.status_bar.clone()) } diff --git a/src/components/button/dropdown.rs b/src/components/button/dropdown.rs index 458499e..c4f5bcb 100644 --- a/src/components/button/dropdown.rs +++ b/src/components/button/dropdown.rs @@ -300,6 +300,7 @@ impl gpui::Render for Dropdown { .bg(theme.background) .text_sm() .text_color(theme.foreground) + .whitespace_nowrap() .child(Label::new(label.clone())) .child(Label::new(arrow).text_color(theme.muted)); @@ -308,14 +309,14 @@ impl gpui::Render for Dropdown { } else { trigger = trigger .cursor_pointer() - .hover(|s: gpui::StyleRefinement| s.bg(theme.selection)); + .hover(|s: gpui::StyleRefinement| s.bg(theme.hover)); } trigger.into_any_element() }; let mut trigger_wrap = gpui::div() - .min_w(gpui::rems(12.0)) + .flex_shrink_0() .child(trigger); if !disabled { trigger_wrap = trigger_wrap.on_mouse_down( diff --git a/src/main.rs b/src/main.rs index 444243b..26061b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod components; mod models; mod task; mod theme; +mod ui; mod view; fn main() { diff --git a/src/theme.rs b/src/theme.rs index bbfe4ec..a806e63 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,79 +1,182 @@ +use gpui::prelude::*; + pub type Color = gpui::Rgba; #[derive(Debug, Clone)] pub struct Theme { pub background: Color, - pub foreground: Color, pub panel: Color, + pub card: Color, + pub raised: Color, + + pub foreground: Color, pub muted: Color, + pub disabled_fg: Color, + pub accent: Color, + pub focus_ring: Color, + pub border: Color, + pub divider: Color, + + pub field_bg: Color, + pub field_border: Color, + pub field_placeholder: Color, + + pub hover: Color, + pub selection: Color, + pub selection_foreground: Color, + + pub backdrop: Color, + pub error: Color, pub success: Color, pub warning: Color, pub info: Color, - pub selection: Color, - pub selection_foreground: Color, - pub text: Color, - pub text_size: Option>, pub high: Color, pub medium: Color, pub low: Color, + + pub text_size: Option>, } impl Theme { + pub fn alpha(c: Color, a: f32) -> Color { + gpui::Rgba { r: c.r, g: c.g, b: c.b, a: a.clamp(0.0, 1.0) } + } + pub fn dark() -> Self { + let background = gpui::rgb(0x0A0E14); + let panel = gpui::rgb(0x0F1419); + let foreground = gpui::rgb(0xB3B1AD); + let muted = gpui::rgb(0x5C6773); + let accent = gpui::rgb(0xFFB454); + let border = gpui::rgb(0x1F2430); + + let card = gpui::rgb(0x111823); + let raised = gpui::rgb(0x151E2B); + + let divider = Self::alpha(foreground, 0.10); + let hover = Self::alpha(foreground, 0.05); + let field_bg = raised; + let field_border = Self::alpha(foreground, 0.14); + let field_placeholder = Self::alpha(foreground, 0.45); + let focus_ring = Self::alpha(accent, 0.75); + let disabled_fg = Self::alpha(foreground, 0.35); + let backdrop = gpui::rgba(0x0000008C); + + let selection = gpui::rgb(0x273747); + let selection_foreground = gpui::rgb(0xE6E1CF); + + let error = gpui::rgb(0xF07178); + let success = gpui::rgb(0xAAD94C); + let warning = gpui::rgb(0xFFB454); + let info = gpui::rgb(0x59C2FF); + Self { - // ayu-dark - background: gpui::rgb(0x0A0E14), - panel: gpui::rgb(0x0F1419), - foreground: gpui::rgb(0xB3B1AD), - muted: gpui::rgb(0x5C6773), - accent: gpui::rgb(0xFFB454), - border: gpui::rgb(0x1F2430), + background, + panel, + card, + raised, - error: gpui::rgb(0xF07178), - success: gpui::rgb(0xAAD94C), - warning: gpui::rgb(0xFFB454), - info: gpui::rgb(0x59C2FF), + foreground, + muted, + disabled_fg, - selection: gpui::rgb(0x273747), - selection_foreground: gpui::rgb(0xE6E1CF), + accent, + focus_ring, + + border, + divider, + + field_bg, + field_border, + field_placeholder, + + hover, + selection, + selection_foreground, + + backdrop, + + error, + success, + warning, + info, + + high: error, + medium: warning, + low: success, - text: gpui::rgb(0xB3B1AD), text_size: Some(gpui::Size::new(14, 14)), - - high: gpui::rgb(0xF07178), - medium: gpui::rgb(0xFFB454), - low: gpui::rgb(0xAAD94C), } } pub fn light() -> Self { + let background = gpui::rgb(0xFAFAFA); + let panel = gpui::rgb(0xFFFFFF); + let foreground = gpui::rgb(0x5C6166); + let muted = gpui::rgb(0x8A9199); + let accent = gpui::rgb(0xFF8F40); + let border = gpui::rgb(0xE6E6E6); + + let card = gpui::rgb(0xF6F7F9); + let raised = gpui::rgb(0xEFF1F5); + + let divider = Self::alpha(foreground, 0.12); + let hover = Self::alpha(foreground, 0.06); + let field_bg = gpui::rgb(0xFFFFFF); + let field_border = Self::alpha(foreground, 0.18); + let field_placeholder = Self::alpha(foreground, 0.55); + let focus_ring = Self::alpha(accent, 0.70); + let disabled_fg = Self::alpha(foreground, 0.40); + let backdrop = gpui::rgba(0x00000040); + + let selection = gpui::rgb(0xD3EBFF); + let selection_foreground = foreground; + + let error = gpui::rgb(0xE65050); + let success = gpui::rgb(0x86B300); + let warning = gpui::rgb(0xF2AE49); + let info = gpui::rgb(0x399EE6); + Self { - // ayu-light - background: gpui::rgb(0xFAFAFA), - panel: gpui::rgb(0xFFFFFF), - foreground: gpui::rgb(0x5C6166), - muted: gpui::rgb(0x8A9199), - accent: gpui::rgb(0xFF8F40), - border: gpui::rgb(0xE6E6E6), + background, + panel, + card, + raised, - error: gpui::rgb(0xE65050), - success: gpui::rgb(0x86B300), - warning: gpui::rgb(0xF2AE49), - info: gpui::rgb(0x399EE6), + foreground, + muted, + disabled_fg, - selection: gpui::rgb(0xD3EBFF), - selection_foreground: gpui::rgb(0x5C6166), + accent, + focus_ring, + + border, + divider, + + field_bg, + field_border, + field_placeholder, + + hover, + selection, + selection_foreground, + + backdrop, + + error, + success, + warning, + info, + + high: error, + medium: warning, + low: success, - text: gpui::rgb(0x5C6166), text_size: Some(gpui::Size::new(14, 14)), - - high: gpui::rgb(0xE65050), - medium: gpui::rgb(0xF2AE49), - low: gpui::rgb(0x86B300), } } diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..d48dfba --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,82 @@ +use gpui::prelude::*; +use gpui::{px, Pixels}; + +use crate::theme::Theme; + +pub const CARD_RADIUS: Pixels = px(6.0); +pub const CARD_PADDING: Pixels = px(8.0); +pub const SECTION_GAP: Pixels = px(12.0); +pub const INSET_GAP: Pixels = px(8.0); +pub const ROOT_PADDING: Pixels = px(12.0); + +pub fn card_style(div: gpui::Div, theme: &Theme) -> gpui::Div { + div.bg(theme.card) + .border_1() + .border_color(theme.divider) + .rounded(CARD_RADIUS) + .p(CARD_PADDING) +} + +pub fn raised_style(div: gpui::Div, theme: &Theme) -> gpui::Div { + div.bg(theme.raised) + .border_1() + .border_color(theme.divider) + .rounded(CARD_RADIUS) + .p(CARD_PADDING) +} + +pub fn panel_style(div: gpui::Div, theme: &Theme) -> gpui::Div { + div.bg(theme.panel) + .border_1() + .border_color(theme.border) + .rounded(CARD_RADIUS) +} + +pub fn field_style(div: gpui::Div, theme: &Theme, focused: bool) -> gpui::Div { + div.bg(theme.field_bg) + .border_1() + .border_color(if focused { + theme.focus_ring + } else { + theme.field_border + }) + .rounded(CARD_RADIUS) + .px_2() + .py_1() +} + +pub fn divider_h(theme: &Theme) -> gpui::Div { + gpui::div().w_full().h(px(1.0)).bg(theme.divider) +} + +pub fn divider_v(theme: &Theme) -> gpui::Div { + gpui::div().h_full().w(px(1.0)).bg(theme.divider) +} + +pub fn section_header(label: &str, theme: &Theme) -> gpui::Div { + gpui::div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.muted) + .child(label.to_uppercase()) +} + +pub fn priority_badge(priority: &str, theme: &Theme) -> gpui::Div { + let (bg, fg) = match priority { + "High" | "H" => (Theme::alpha(theme.high, 0.15), theme.high), + "Medium" | "M" => (Theme::alpha(theme.medium, 0.15), theme.medium), + "Low" | "L" => (Theme::alpha(theme.low, 0.15), theme.low), + _ => (gpui::rgba(0x00000000), theme.muted), + }; + + gpui::div() + .px_2() + .py(px(2.0)) + .rounded(px(4.0)) + .bg(bg) + .text_color(fg) + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .child(priority.to_string()) +} diff --git a/src/view/sidebar.rs b/src/view/sidebar.rs index 81a8986..f46c8bd 100644 --- a/src/view/sidebar.rs +++ b/src/view/sidebar.rs @@ -1,6 +1,6 @@ -use crate::components::{label::Label, panel::Panel}; use crate::models::{FilterState, ProjectTree}; use crate::theme::ActiveTheme; +use crate::ui::{divider_h, section_header}; use gpui::{Context, Div, Entity, IntoElement, Window, div, prelude::*, px}; #[derive(Debug, Clone)] @@ -100,9 +100,10 @@ impl Sidebar { .gap_2() .px_3() .py_1() + .rounded_sm() .cursor_pointer() .when(is_all_selected, |this| this.bg(theme.selection)) - .hover(|style| style.bg(theme.panel)) + .when(!is_all_selected, |this| this.hover(|style| style.bg(theme.hover))) .on_mouse_down( gpui::MouseButton::Left, cx.listener(|view, _event, window, cx| { @@ -149,9 +150,10 @@ impl Sidebar { .gap_1() .px_3() .py_1() + .rounded_sm() .cursor_pointer() .when(is_selected, |this| this.bg(theme.selection)) - .hover(|style| style.bg(theme.panel)) + .when(!is_selected, |this| this.hover(|style| style.bg(theme.hover))) .child(div().w(px(indent as f32))) .child( div() @@ -230,9 +232,10 @@ impl Sidebar { .gap_2() .px_3() .py_1() + .rounded_sm() .cursor_pointer() .when(is_active, |this| this.bg(theme.selection)) - .hover(|style| style.bg(theme.panel)) + .when(!is_active, |this| this.hover(|style| style.bg(theme.hover))) .on_mouse_down( gpui::MouseButton::Left, cx.listener(move |view, _event, window, cx| { @@ -279,115 +282,96 @@ impl Render for Sidebar { let has_project = filter.selected_project.is_some(); let has_tags = !filter.active_tags.is_empty(); - Panel::new("Sidebar").border(1.0).padding(0.0).child( - div() - .flex() - .flex_col() - .size_full() - .bg(theme.background) - .child( - div() - .flex() - .flex_col() - .flex_1() - .min_h_0() - .overflow_hidden() - .child( - div() - .flex_shrink_0() - .flex() - .items_center() - .justify_between() - .px_3() - .py_2() - .border_b_1() - .border_color(theme.border) - .child( - Label::new("PROJECTS") - .text_sm() - .font_weight(gpui::FontWeight::BOLD) - .text_color(theme.foreground), + div() + .flex() + .flex_col() + .size_full() + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_hidden() + .child( + div() + .flex_shrink_0() + .flex() + .items_center() + .justify_between() + .px_2() + .py_2() + .child(section_header("Projects", &theme)) + .when(has_project, |this| { + this.child( + div() + .id("clear-project") + .text_xs() + .text_color(theme.muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.accent)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|view, _, window, cx| { + view.handle_clear_project(window, cx); + }), + ) + .child("Clear"), ) - .when(has_project, |this| { - this.child( - div() - .id("clear-project") - .text_xs() - .text_color(theme.muted) - .cursor_pointer() - .hover(|s| s.text_color(theme.accent)) - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(|view, _, window, cx| { - view.handle_clear_project(window, cx); - }), - ) - .child("Clear"), - ) - }), - ) - .child( - div() - .id("sidebar-projects") - .flex_1() - .min_h_0() - .py_1() - .overflow_y_scroll() - .children(projects), - ), - ) - .child(div().flex_shrink_0().h_px().bg(theme.border)) - .child( - div() - .flex() - .flex_col() - .flex_1() - .min_h_0() - .overflow_hidden() - .child( - div() - .flex_shrink_0() - .flex() - .items_center() - .justify_between() - .px_3() - .py_2() - .border_b_1() - .border_color(theme.border) - .child( - Label::new("TAGS") - .text_sm() - .font_weight(gpui::FontWeight::BOLD) - .text_color(theme.foreground), + }), + ) + .child( + div() + .id("sidebar-projects") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .children(projects), + ), + ) + .child(divider_h(&theme).my_1()) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_hidden() + .child( + div() + .flex_shrink_0() + .flex() + .items_center() + .justify_between() + .px_2() + .py_2() + .child(section_header("Tags", &theme)) + .when(has_tags, |this| { + this.child( + div() + .id("clear-tags") + .text_xs() + .text_color(theme.muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.accent)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|view, _, window, cx| { + view.handle_clear_tags(window, cx); + }), + ) + .child("Clear"), ) - .when(has_tags, |this| { - this.child( - div() - .id("clear-tags") - .text_xs() - .text_color(theme.muted) - .cursor_pointer() - .hover(|s| s.text_color(theme.accent)) - .on_mouse_down( - gpui::MouseButton::Left, - cx.listener(|view, _, window, cx| { - view.handle_clear_tags(window, cx); - }), - ) - .child("Clear"), - ) - }), - ) - .child( - div() - .id("sidebar-tags") - .flex_1() - .min_h_0() - .py_1() - .overflow_y_scroll() - .children(tags), - ), - ), - ) + }), + ) + .child( + div() + .id("sidebar-tags") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .children(tags), + ), + ) } } diff --git a/src/view/status_bar.rs b/src/view/status_bar.rs index a6b0949..068d0ab 100644 --- a/src/view/status_bar.rs +++ b/src/view/status_bar.rs @@ -1,6 +1,7 @@ use gpui::{Context, IntoElement, Render, Window, div, prelude::*, px}; use crate::theme::ActiveTheme; +use crate::ui::{card_style, CARD_RADIUS}; pub struct StatusBar { // TODO: Add vim_mode, sync_state, last_sync, etc. @@ -16,16 +17,14 @@ impl Render for StatusBar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); - div() + card_style(div(), &theme) .flex() .items_center() .justify_between() .w_full() - .h(px(30.0)) - .px_4() + .h(px(28.0)) + .px_3() .py_1() - .bg(theme.panel) - .border_b_1() - .border_color(theme.border) + .rounded(CARD_RADIUS) } } diff --git a/src/view/task_table.rs b/src/view/task_table.rs index fe304ef..fdd8195 100644 --- a/src/view/task_table.rs +++ b/src/view/task_table.rs @@ -13,6 +13,7 @@ use crate::{ models::{DueFilter, FilterState, PriorityFilter, StatusFilter}, task::{self, TaskFilter, TaskService}, theme::{self, ActiveTheme}, + ui::priority_badge, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -604,15 +605,14 @@ impl TaskTable { let bar = gpui::div() .id("filter-bar") .flex() - .flex_wrap() - .gap_2() + .gap_3() .items_center() .px_4() .py_2() - .bg(theme.panel) .child( gpui::div() - .w(gpui::rems(14.0)) + .flex_1() + .min_w(gpui::rems(12.0)) .child(self.search_input.clone()), ) .child(self.status_dropdown.clone()) @@ -622,13 +622,14 @@ impl TaskTable { this.child( gpui::div() .id("clear-all-filters") + .flex_shrink_0() .px_2() .py_1() .rounded_md() .text_sm() .text_color(theme.error) .cursor_pointer() - .hover(|s| s.bg(theme.selection)) + .hover(|s| s.bg(theme.hover)) .on_mouse_down( gpui::MouseButton::Left, cx.listener(|table, _, _, cx| { @@ -702,10 +703,9 @@ impl TaskTable { .gap_2() .px_4() .py_2() - .border_t_1() - .border_color(theme.border) + .bg(theme.raised) .border_b_1() - .bg(theme.panel) + .border_color(theme.divider) .text_sm() .font_weight(gpui::FontWeight::MEDIUM) .child( @@ -756,12 +756,12 @@ impl TaskTable { .px_4() .py_1() .border_b_1() - .border_color(theme.border) + .border_color(theme.divider) .text_color(theme.foreground) .when(selected, |d| { d.bg(theme.selection).text_color(theme.selection_foreground) }) - .when(!selected, |d| d.hover(|s| s.bg(theme.panel))) + .when(!selected, |d| d.hover(|s| s.bg(theme.hover))) .cursor_pointer() .on_mouse_down( gpui::MouseButton::Left, @@ -802,11 +802,9 @@ impl TaskTable { components::label::Label::new(row.due.clone()).text_color(self.due_color(row, cx)), )) .child( - gpui::div().w(gpui::rems(5.0)).child( - components::label::Label::new(row.priority.clone()) - .text_color(self.priority_color(row, cx)) - .font_weight(gpui::FontWeight::BOLD), - ), + gpui::div() + .w(gpui::rems(5.0)) + .child(priority_badge(&row.priority, theme)), ) .child( gpui::div().w(gpui::rems(6.0)).child( @@ -831,8 +829,8 @@ impl TaskTable { .px_4() .py_2() .border_t_1() - .border_color(theme.border) - .bg(theme.panel) + .border_color(theme.divider) + .bg(theme.raised) .text_sm() .child( components::label::Label::new(format!( @@ -866,13 +864,15 @@ impl TaskTable { .px_2() .py_1() .rounded_md() + .border_1() + .border_color(theme.divider) .text_color(if can_prev { theme.foreground } else { - theme.muted + theme.disabled_fg }) .when(can_prev, |d| { - d.cursor_pointer().hover(|s| s.bg(theme.selection)) + d.cursor_pointer().hover(|s| s.bg(theme.hover)) }) .when(!can_prev, |d| d.cursor_not_allowed()) .on_mouse_down( @@ -887,13 +887,15 @@ impl TaskTable { .px_2() .py_1() .rounded_md() + .border_1() + .border_color(theme.divider) .text_color(if can_next { theme.foreground } else { - theme.muted + theme.disabled_fg }) .when(can_next, |d| { - d.cursor_pointer().hover(|s| s.bg(theme.selection)) + d.cursor_pointer().hover(|s| s.bg(theme.hover)) }) .when(!can_next, |d| d.cursor_not_allowed()) .on_mouse_down( @@ -924,7 +926,7 @@ impl gpui::Render for TaskTable { .size_full() .items_center() .justify_center() - .child(components::label::Label::new("Loading...").text_color(theme.text)); + .child(components::label::Label::new("Loading...").text_color(theme.foreground)); } let current_page = self.get_current_page_rows(); @@ -945,7 +947,7 @@ impl gpui::Render for TaskTable { .min_h_0() .overflow_hidden() .bg(theme.background) - .child(gpui::div().h(self.filter_bar_height)) + .child(gpui::div().h(self.filter_bar_height).mb_4()) .child(header) .child( gpui::div()