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.
This commit is contained in:
Ignacio Perez
2025-12-27 18:44:45 -03:00
parent 3ce8886fc9
commit 58f4f2bfd0
8 changed files with 377 additions and 199 deletions
+21 -15
View File
@@ -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())
}
+3 -2
View File
@@ -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(
+1
View File
@@ -5,6 +5,7 @@ mod components;
mod models;
mod task;
mod theme;
mod ui;
mod view;
fn main() {
+144 -41
View File
@@ -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<gpui::Size<u32>>,
pub high: Color,
pub medium: Color,
pub low: Color,
pub text_size: Option<gpui::Size<u32>>,
}
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),
}
}
+82
View File
@@ -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())
}
+96 -112
View File
@@ -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),
),
)
}
}
+5 -6
View File
@@ -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<Self>) -> 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)
}
}
+25 -23
View File
@@ -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()