diff --git a/crates/ely_app/src/shell/chrome/home/favorites.rs b/crates/ely_app/src/shell/chrome/home/favorites.rs new file mode 100644 index 0000000..d305826 --- /dev/null +++ b/crates/ely_app/src/shell/chrome/home/favorites.rs @@ -0,0 +1,118 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::BrowserTab; +use gpui::{ + AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, rgba, +}; +use gpui_component::IconName; + +use crate::shell::ElyShell; + +use super::section::render_section_chevron_label; +use super::style::{ADD_TILE_BG, CARD_BG, CARD_BG_HOVER, card_shadow}; + +pub(crate) fn render_favorites_grid( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + if snapshot.favorites.is_empty() { + return div().into_any_element(); + } + + div() + .flex() + .flex_col() + .gap(px(12.0)) + .child(render_section_chevron_label("Favorites")) + .child( + div() + .grid() + .grid_cols(7) + .gap(px(10.0)) + .children( + snapshot + .favorites + .iter() + .enumerate() + .map(|(index, tab)| render_favorite_tile(index, tab, cx)), + ) + .child(render_add_favorite_tile(cx)), + ) + .into_any_element() +} + +fn render_favorite_tile( + index: usize, + tab: &BrowserTab, + cx: &mut Context, +) -> AnyElement { + let tab_id = tab.id().clone(); + let initial = tab + .title() + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + let title = tab.title().to_string(); + + div() + .id(SharedString::from(format!("fav-tile-{index}"))) + .h(px(96.0)) + .rounded(px(16.0)) + .bg(rgba(CARD_BG)) + .shadow(card_shadow()) + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(8.0)) + .cursor_pointer() + .hover(|style| style.bg(rgba(CARD_BG_HOVER))) + .active(|style| style.opacity(0.85)) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.select_tab(&tab_id, window, cx); + })) + .child( + div() + .size(px(28.0)) + .rounded(px(7.0)) + .bg(rgb(colors::ACCENT)) + .flex() + .items_center() + .justify_center() + .text_size(px(14.0)) + .font_weight(FontWeight(600.0)) + .text_color(rgb(0xffffff)) + .child(initial), + ) + .child( + div() + .text_size(px(11.5)) + .text_color(rgb(colors::INK_3)) + .max_w(px(80.0)) + .truncate() + .child(title), + ) + .into_any_element() +} + +fn render_add_favorite_tile(cx: &mut Context) -> AnyElement { + div() + .id(SharedString::from("fav-tile-add")) + .h(px(96.0)) + .rounded(px(16.0)) + .bg(rgba(ADD_TILE_BG)) + .flex() + .items_center() + .justify_center() + .text_color(rgb(colors::INK_4)) + .cursor_pointer() + .hover(|style| style.bg(rgba(CARD_BG))) + .on_click(cx.listener(|shell, _, window, cx| { + shell.open_internal_tab("ely://bookmarks", window, cx); + })) + .child(IconName::Plus) + .into_any_element() +} diff --git a/crates/ely_app/src/shell/chrome/home/hero.rs b/crates/ely_app/src/shell/chrome/home/hero.rs new file mode 100644 index 0000000..f4cea0f --- /dev/null +++ b/crates/ely_app/src/shell/chrome/home/hero.rs @@ -0,0 +1,162 @@ +use ely_design_system::colors; +use gpui::{ + AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, rgba, +}; +use gpui_component::IconName; + +use crate::shell::ElyShell; + +use super::style::{ + ARROW_CHIP_BG, PILL_BG, PILL_BG_HOVER, SEARCH_BG, card_shadow, soft_shadow, +}; + +pub(crate) fn render_hero(greeting: String, cx: &mut Context) -> AnyElement { + div() + .flex() + .flex_col() + .items_center() + .gap(px(14.0)) + .child(render_greeting_row(greeting)) + .child(render_serif_headline()) + .child(render_search_bar(cx)) + .child(render_suggestion_pills(cx)) + .into_any_element() +} + +fn render_greeting_row(text: String) -> AnyElement { + div() + .flex() + .items_center() + .gap(px(8.0)) + .text_size(px(13.0)) + .text_color(rgb(colors::INK_3)) + .child( + div() + .text_color(rgb(colors::ACCENT_LIGHT)) + .child(IconName::Sun), + ) + .child(text) + .into_any_element() +} + +fn render_serif_headline() -> AnyElement { + div() + .text_size(px(64.0)) + .font_weight(FontWeight(400.0)) + .text_color(rgb(colors::INK)) + .child("Where focus finds flow.") + .into_any_element() +} + +fn render_search_bar(cx: &mut Context) -> AnyElement { + div() + .id(SharedString::from("home-search")) + .w(px(640.0)) + .h(px(54.0)) + .rounded(px(14.0)) + .bg(rgba(SEARCH_BG)) + .shadow(card_shadow()) + .px(px(16.0)) + .flex() + .items_center() + .gap(px(12.0)) + .cursor_pointer() + .on_click(cx.listener(|shell, _, window, cx| { + shell.focus_address_bar(window, cx); + })) + .child( + div() + .text_color(rgb(colors::INK_3)) + .child(IconName::Search), + ) + .child( + div() + .flex_1() + .text_size(px(14.0)) + .text_color(rgb(colors::INK_4)) + .child("Search the web or ELY"), + ) + .child( + div() + .size(px(28.0)) + .rounded(px(8.0)) + .bg(rgba(ARROW_CHIP_BG)) + .flex() + .items_center() + .justify_center() + .text_color(rgb(colors::INK_3)) + .child(IconName::ArrowRight), + ) + .into_any_element() +} + +fn render_suggestion_pills(cx: &mut Context) -> AnyElement { + div() + .flex() + .items_center() + .justify_center() + .gap(px(8.0)) + .child(render_pill( + "pill-search-tabs", + IconName::Search, + "Search Tabs", + cx, + |shell, window, cx| shell.focus_address_bar(window, cx), + )) + .child(render_pill( + "pill-switch-workspace", + IconName::LayoutDashboard, + "Switch Workspace", + cx, + |shell, window, cx| shell.cycle_to_next_space(window, cx), + )) + .child(render_pill( + "pill-open-history", + IconName::Undo2, + "Open History", + cx, + |shell, window, cx| shell.open_internal_tab("ely://history", window, cx), + )) + .into_any_element() +} + +fn render_pill( + id: &'static str, + icon: IconName, + label: &'static str, + cx: &mut Context, + handler: F, +) -> AnyElement +where + F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context) + 'static, +{ + div() + .id(SharedString::from(id)) + .rounded(px(999.0)) + .bg(rgba(PILL_BG)) + .shadow(soft_shadow()) + .px(px(12.0)) + .py(px(6.0)) + .flex() + .items_center() + .gap(px(6.0)) + .cursor_pointer() + .hover(|style| style.bg(rgba(PILL_BG_HOVER))) + .active(|style| style.opacity(0.82)) + .on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx))) + .child( + div() + .text_color(rgb(colors::INK_3)) + .text_size(px(12.0)) + .child(icon), + ) + .child( + div() + .text_size(px(12.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK_2)) + .child(label), + ) + .into_any_element() +} diff --git a/crates/ely_app/src/shell/chrome/home/mod.rs b/crates/ely_app/src/shell/chrome/home/mod.rs new file mode 100644 index 0000000..57ead2f --- /dev/null +++ b/crates/ely_app/src/shell/chrome/home/mod.rs @@ -0,0 +1,40 @@ +use std::time::SystemTime; + +use ely_browser_core::BrowserSnapshot; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px}; +use gpui_component::scroll::ScrollableElement; + +use crate::shell::ElyShell; + +mod favorites; +mod hero; +mod recap; +mod section; +mod style; +mod time; + +pub(crate) fn render_home_page( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + let greeting = time::greeting_for_now(SystemTime::now(), &snapshot.active_profile_name); + + div() + .flex_1() + .h_full() + .overflow_y_scrollbar() + .child( + div() + .size_full() + .pt(px(48.0)) + .px(px(64.0)) + .pb(px(28.0)) + .flex() + .flex_col() + .gap(px(40.0)) + .child(hero::render_hero(greeting, cx)) + .child(favorites::render_favorites_grid(snapshot, cx)) + .child(recap::render_recap(snapshot, cx)), + ) + .into_any_element() +} diff --git a/crates/ely_app/src/shell/chrome/home/recap.rs b/crates/ely_app/src/shell/chrome/home/recap.rs new file mode 100644 index 0000000..2994270 --- /dev/null +++ b/crates/ely_app/src/shell/chrome/home/recap.rs @@ -0,0 +1,377 @@ +use std::time::SystemTime; + +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{HistoryEntry, ReadingListEntry}; +use gpui::{ + AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, linear_gradient, px, rgb, + rgba, +}; +use gpui_component::IconName; + +use crate::shell::ElyShell; + +use super::style::{CARD_BG, CARD_BG_HOVER, card_shadow}; +use super::time::relative_time_label; + +pub(crate) fn render_recap( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + if snapshot.history_entries.is_empty() && snapshot.reading_list.is_empty() { + return div().into_any_element(); + } + + let now = SystemTime::now(); + + div() + .grid() + .grid_cols(8) + .gap(px(12.0)) + .child( + div() + .col_span(5) + .child(render_continue_card(snapshot, now, cx)), + ) + .child( + div() + .col_span(3) + .child(render_activity_card(snapshot, now, cx)), + ) + .into_any_element() +} + +fn render_continue_card( + snapshot: &BrowserSnapshot, + now: SystemTime, + cx: &mut Context, +) -> AnyElement { + let recent_history: Vec<&HistoryEntry> = snapshot.history_entries.iter().rev().take(3).collect(); + let featured = snapshot.reading_list.first(); + + div() + .rounded(px(16.0)) + .bg(rgba(CARD_BG)) + .shadow(card_shadow()) + .p(px(16.0)) + .grid() + .grid_cols(2) + .gap(px(16.0)) + .child(render_continue_history(recent_history, now, cx)) + .child(render_reading_list_cover(featured, cx)) + .into_any_element() +} + +fn render_continue_history( + entries: Vec<&HistoryEntry>, + now: SystemTime, + cx: &mut Context, +) -> AnyElement { + div() + .flex() + .flex_col() + .gap(px(14.0)) + .child( + div() + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK)) + .child("Continue where you left off"), + ) + .child(if entries.is_empty() { + render_empty_history_state() + } else { + div() + .flex() + .flex_col() + .gap(px(10.0)) + .children( + entries + .into_iter() + .enumerate() + .map(|(index, entry)| render_history_row(index, entry, now, cx)), + ) + .into_any_element() + }) + .into_any_element() +} + +fn render_empty_history_state() -> AnyElement { + div() + .text_size(px(12.0)) + .text_color(rgb(colors::INK_4)) + .child("Your recently visited pages will appear here.") + .into_any_element() +} + +fn render_history_row( + index: usize, + entry: &HistoryEntry, + now: SystemTime, + cx: &mut Context, +) -> AnyElement { + let url = entry.url().clone(); + let title = entry.title().to_string(); + let host = entry.url().host().unwrap_or_default().to_string(); + let when = relative_time_label(now, entry.visited_at()); + let initial = title.chars().next().unwrap_or('?').to_uppercase().to_string(); + + div() + .id(SharedString::from(format!("continue-row-{index}"))) + .flex() + .items_center() + .gap(px(12.0)) + .py(px(4.0)) + .cursor_pointer() + .on_click(cx.listener(move |shell, _, window, cx| { + shell.open_internal_tab(url.as_str(), window, cx); + })) + .child( + div() + .size(px(26.0)) + .rounded(px(7.0)) + .bg(rgba(CARD_BG_HOVER)) + .flex() + .items_center() + .justify_center() + .text_size(px(12.0)) + .font_weight(FontWeight(600.0)) + .text_color(rgb(colors::INK_2)) + .child(initial), + ) + .child( + div() + .min_w_0() + .flex_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK)) + .truncate() + .child(title), + ) + .child( + div() + .text_size(px(11.5)) + .text_color(rgb(colors::INK_4)) + .truncate() + .child(format!("{host} · {when}")), + ), + ) + .into_any_element() +} + +fn render_reading_list_cover( + featured: Option<&ReadingListEntry>, + cx: &mut Context, +) -> AnyElement { + let title = featured + .map(|entry| entry.title().to_string()) + .unwrap_or_else(|| "Add an article to your reading list".to_string()); + let subtitle = featured + .map(|entry| entry.display_url()) + .unwrap_or_else(|| "Reading List".to_string()); + let url = featured.map(|entry| entry.source_url().clone()); + + let mut cover = div() + .id(SharedString::from("reading-list-cover")) + .min_h(px(200.0)) + .rounded(px(12.0)) + .relative() + .overflow_hidden() + .bg(linear_gradient( + 135.0, + linear_color_stop(hsla(20.0 / 360.0, 0.36, 0.78, 1.0), 0.0), + linear_color_stop(hsla(220.0 / 360.0, 0.30, 0.71, 1.0), 1.0), + )) + .cursor_pointer(); + + if let Some(target) = url { + cover = cover.on_click(cx.listener(move |shell, _, window, cx| { + shell.open_internal_tab(target.as_str(), window, cx); + })); + } else { + cover = cover.on_click(cx.listener(|shell, _, window, cx| { + shell.open_internal_tab("ely://reading-list", window, cx); + })); + } + + cover + .child( + div() + .absolute() + .top(px(8.0)) + .right(px(8.0)) + .px(px(10.0)) + .py(px(4.0)) + .rounded(px(999.0)) + .bg(rgba(BADGE_BG)) + .text_size(px(10.5)) + .text_color(rgb(0xffffff)) + .child(format!("Reading List · {}", featured.map(|_| 1).unwrap_or(0))), + ) + .child( + div() + .absolute() + .left_0() + .right_0() + .bottom_0() + .p(px(14.0)) + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(px(18.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(0xffffff)) + .child(title), + ) + .child( + div() + .text_size(px(11.0)) + .text_color(rgb(0xe6e3de)) + .child(subtitle), + ), + ) + .into_any_element() +} + +fn render_activity_card( + snapshot: &BrowserSnapshot, + now: SystemTime, + cx: &mut Context, +) -> AnyElement { + let entries: Vec<&HistoryEntry> = snapshot.history_entries.iter().rev().take(3).collect(); + + div() + .rounded(px(16.0)) + .bg(rgba(CARD_BG)) + .shadow(card_shadow()) + .p(px(16.0)) + .flex() + .flex_col() + .gap(px(12.0)) + .child( + div() + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK)) + .child("Recent activity"), + ) + .child(if entries.is_empty() { + render_empty_activity_state() + } else { + div() + .flex() + .flex_col() + .gap(px(12.0)) + .children(entries.into_iter().enumerate().map(|(index, entry)| { + render_activity_row(index, entry, now, cx) + })) + .into_any_element() + }) + .child(render_view_all_link(cx)) + .into_any_element() +} + +fn render_empty_activity_state() -> AnyElement { + div() + .text_size(px(12.0)) + .text_color(rgb(colors::INK_4)) + .child("Activity from your tabs will land here.") + .into_any_element() +} + +fn render_activity_row( + index: usize, + entry: &HistoryEntry, + now: SystemTime, + cx: &mut Context, +) -> AnyElement { + let url = entry.url().clone(); + let title = entry.title().to_string(); + let display_url = entry.url().as_str().to_string(); + let when = relative_time_label(now, entry.visited_at()); + + div() + .id(SharedString::from(format!("activity-row-{index}"))) + .flex() + .items_start() + .gap(px(10.0)) + .cursor_pointer() + .on_click(cx.listener(move |shell, _, window, cx| { + shell.open_internal_tab(url.as_str(), window, cx); + })) + .child( + div() + .size(px(24.0)) + .rounded(px(6.0)) + .bg(rgba(CARD_BG_HOVER)) + .flex() + .items_center() + .justify_center() + .text_color(rgb(colors::INK_3)) + .child(IconName::Globe), + ) + .child( + div() + .min_w_0() + .flex_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(px(11.5)) + .text_color(rgb(colors::INK_3)) + .child("You visited"), + ) + .child( + div() + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK)) + .truncate() + .child(title), + ) + .child( + div() + .text_size(px(10.5)) + .text_color(rgb(colors::INK_4)) + .truncate() + .child(display_url), + ), + ) + .child( + div() + .text_size(px(10.5)) + .text_color(rgb(colors::INK_4)) + .child(when), + ) + .into_any_element() +} + +fn render_view_all_link(cx: &mut Context) -> AnyElement { + div() + .id(SharedString::from("activity-view-all")) + .pt(px(10.0)) + .border_t_1() + .border_color(rgba(colors::DIVIDER)) + .text_size(px(11.5)) + .text_color(rgb(colors::INK_3)) + .cursor_pointer() + .hover(|style| style.text_color(rgb(colors::INK))) + .on_click(cx.listener(|shell, _, window, cx| { + shell.open_internal_tab("ely://history", window, cx); + })) + .child("View all history →") + .into_any_element() +} + +const BADGE_BG: u32 = 0x00000059; diff --git a/crates/ely_app/src/shell/chrome/home/section.rs b/crates/ely_app/src/shell/chrome/home/section.rs new file mode 100644 index 0000000..040452a --- /dev/null +++ b/crates/ely_app/src/shell/chrome/home/section.rs @@ -0,0 +1,20 @@ +use ely_design_system::colors; +use gpui::{AnyElement, FontWeight, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::IconName; + +pub(crate) fn render_section_chevron_label(label: &'static str) -> AnyElement { + div() + .flex() + .items_center() + .gap(px(6.0)) + .text_color(rgb(colors::INK_3)) + .text_size(px(12.5)) + .font_weight(FontWeight(500.0)) + .child(label) + .child( + div() + .text_color(rgb(colors::INK_4)) + .child(IconName::ChevronDown), + ) + .into_any_element() +} diff --git a/crates/ely_app/src/shell/chrome/home/style.rs b/crates/ely_app/src/shell/chrome/home/style.rs new file mode 100644 index 0000000..0830962 --- /dev/null +++ b/crates/ely_app/src/shell/chrome/home/style.rs @@ -0,0 +1,35 @@ +use gpui::{BoxShadow, hsla, point, px}; + +pub(crate) const SEARCH_BG: u32 = 0xffffffd9; +pub(crate) const ARROW_CHIP_BG: u32 = 0x281e140a; +pub(crate) const PILL_BG: u32 = 0xffffff8c; +pub(crate) const PILL_BG_HOVER: u32 = 0xffffffd9; +pub(crate) const CARD_BG: u32 = 0xffffffc7; +pub(crate) const CARD_BG_HOVER: u32 = 0xffffffeb; +pub(crate) const ADD_TILE_BG: u32 = 0xffffff7f; + +pub(crate) fn card_shadow() -> Vec { + vec![BoxShadow { + color: hsla(25.0 / 360.0, 0.33, 0.12, 0.18), + offset: point(px(0.0), px(8.0)), + blur_radius: px(24.0), + spread_radius: px(-10.0), + }] +} + +pub(crate) fn soft_shadow() -> Vec { + vec![ + BoxShadow { + color: hsla(0.0, 0.0, 1.0, 0.7), + offset: point(px(0.0), px(1.0)), + blur_radius: px(0.0), + spread_radius: px(0.0), + }, + BoxShadow { + color: hsla(25.0 / 360.0, 0.33, 0.12, 0.08), + offset: point(px(0.0), px(0.0)), + blur_radius: px(0.0), + spread_radius: px(1.0), + }, + ] +} diff --git a/crates/ely_app/src/shell/chrome/home/time.rs b/crates/ely_app/src/shell/chrome/home/time.rs new file mode 100644 index 0000000..adf602e --- /dev/null +++ b/crates/ely_app/src/shell/chrome/home/time.rs @@ -0,0 +1,113 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) fn greeting_for_now(now: SystemTime, name: &str) -> String { + let phase = match now.duration_since(UNIX_EPOCH) { + Ok(duration) => { + let local_seconds = duration.as_secs() as i64; + let day_seconds = local_seconds.rem_euclid(86_400); + let hour = (day_seconds / 3_600) as u32; + day_phase(hour) + } + Err(_) => "today", + }; + + if name.is_empty() { + format!("Good {phase}") + } else { + format!("Good {phase}, {name}") + } +} + +pub(crate) fn day_phase(hour_utc: u32) -> &'static str { + match hour_utc { + 5..=11 => "morning", + 12..=17 => "afternoon", + _ => "evening", + } +} + +pub(crate) fn relative_time_label(now: SystemTime, then: SystemTime) -> String { + let elapsed = match now.duration_since(then) { + Ok(duration) => duration, + Err(_) => return "just now".to_string(), + }; + + let seconds = elapsed.as_secs(); + + if seconds < 60 { + return "just now".to_string(); + } + if seconds < 3_600 { + let minutes = seconds / 60; + return format!("{minutes} min ago"); + } + if seconds < 86_400 { + let hours = seconds / 3_600; + let unit = if hours == 1 { "hour" } else { "hours" }; + return format!("{hours} {unit} ago"); + } + let days = seconds / 86_400; + let unit = if days == 1 { "day" } else { "days" }; + format!("{days} {unit} ago") +} + +#[cfg(test)] +mod tests { + use super::{day_phase, greeting_for_now, relative_time_label}; + use std::time::{Duration, UNIX_EPOCH}; + + #[test] + fn day_phase_assigns_morning_to_seven_am() { + assert_eq!(day_phase(7), "morning"); + } + + #[test] + fn day_phase_assigns_afternoon_to_one_pm() { + assert_eq!(day_phase(13), "afternoon"); + } + + #[test] + fn day_phase_assigns_evening_to_eleven_pm() { + assert_eq!(day_phase(23), "evening"); + } + + #[test] + fn greeting_includes_profile_name() { + let two_pm = UNIX_EPOCH + Duration::from_secs(14 * 3_600); + assert_eq!(greeting_for_now(two_pm, "Alex"), "Good afternoon, Alex"); + } + + #[test] + fn greeting_omits_name_when_blank() { + let nine_am = UNIX_EPOCH + Duration::from_secs(9 * 3_600); + assert_eq!(greeting_for_now(nine_am, ""), "Good morning"); + } + + #[test] + fn relative_time_label_uses_minutes_under_an_hour() { + let now = UNIX_EPOCH + Duration::from_secs(180); + let then = UNIX_EPOCH + Duration::from_secs(60); + assert_eq!(relative_time_label(now, then), "2 min ago"); + } + + #[test] + fn relative_time_label_uses_hours_within_a_day() { + let now = UNIX_EPOCH + Duration::from_secs(7_200); + let then = UNIX_EPOCH; + assert_eq!(relative_time_label(now, then), "2 hours ago"); + } + + #[test] + fn relative_time_label_singularizes_one_hour() { + let now = UNIX_EPOCH + Duration::from_secs(3_600); + let then = UNIX_EPOCH; + assert_eq!(relative_time_label(now, then), "1 hour ago"); + } + + #[test] + fn relative_time_label_uses_days_above_a_day() { + let now = UNIX_EPOCH + Duration::from_secs(86_400 * 3); + let then = UNIX_EPOCH; + assert_eq!(relative_time_label(now, then), "3 days ago"); + } +} diff --git a/crates/ely_app/src/shell/chrome/mod.rs b/crates/ely_app/src/shell/chrome/mod.rs index 1e2d938..2b1830f 100644 --- a/crates/ely_app/src/shell/chrome/mod.rs +++ b/crates/ely_app/src/shell/chrome/mod.rs @@ -1,7 +1,9 @@ +pub(crate) mod home; pub(crate) mod sidebar_header; pub(crate) mod topbar; pub(crate) mod wallpaper; +pub(crate) use home::render_home_page; pub(crate) use sidebar_header::render_sidebar_header; pub(crate) use topbar::render_topbar; pub(crate) use wallpaper::{WallpaperTheme, render_wallpaper}; diff --git a/crates/ely_app/src/shell/internal_pages/new_tab.rs b/crates/ely_app/src/shell/internal_pages/new_tab.rs index efe6189..ba32c74 100644 --- a/crates/ely_app/src/shell/internal_pages/new_tab.rs +++ b/crates/ely_app/src/shell/internal_pages/new_tab.rs @@ -1,15 +1,8 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - use ely_browser_core::BrowserSnapshot; -use ely_design_system::colors; -use ely_domain::BrowserTab; -use gpui::{ - AnyElement, BoxShadow, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, - SharedString, StatefulInteractiveElement, Styled, div, hsla, point, px, rgb, rgba, -}; -use gpui_component::{IconName, scroll::ScrollableElement}; +use gpui::{AnyElement, Context}; use super::ElyShell; +use crate::shell::chrome::render_home_page; impl ElyShell { pub(super) fn render_new_tab_page( @@ -17,464 +10,6 @@ impl ElyShell { snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { - let greeting = greeting_for_now(SystemTime::now(), &snapshot.active_profile_name); - - div() - .flex_1() - .h_full() - .overflow_y_scrollbar() - .child( - div() - .size_full() - .pt(px(48.0)) - .px(px(64.0)) - .pb(px(28.0)) - .flex() - .flex_col() - .gap(px(48.0)) - .child(render_hero(greeting, cx)) - .child(render_favorites_grid(snapshot, cx)) - .child(render_tabs_section(snapshot, cx)), - ) - .into_any_element() - } -} - -fn render_hero(greeting: String, cx: &mut Context) -> AnyElement { - div() - .flex() - .flex_col() - .items_center() - .gap(px(14.0)) - .child(render_greeting_row(greeting)) - .child(render_serif_headline()) - .child(render_search_bar(cx)) - .child(render_suggestion_pills(cx)) - .into_any_element() -} - -fn render_greeting_row(text: String) -> AnyElement { - div() - .flex() - .items_center() - .gap(px(8.0)) - .text_size(px(13.0)) - .text_color(rgb(colors::INK_3)) - .child( - div() - .text_color(rgb(colors::ACCENT_LIGHT)) - .child(IconName::Sun), - ) - .child(text) - .into_any_element() -} - -fn render_serif_headline() -> AnyElement { - div() - .text_size(px(64.0)) - .font_weight(FontWeight(400.0)) - .text_color(rgb(colors::INK)) - .child("Where focus finds flow.") - .into_any_element() -} - -fn render_search_bar(cx: &mut Context) -> AnyElement { - div() - .id(SharedString::from("home-search")) - .w(px(640.0)) - .h(px(54.0)) - .rounded(px(14.0)) - .bg(rgba(SEARCH_BG)) - .shadow(card_shadow()) - .px(px(16.0)) - .flex() - .items_center() - .gap(px(12.0)) - .cursor_pointer() - .on_click(cx.listener(|shell, _, window, cx| { - shell.focus_address_bar(window, cx); - })) - .child( - div() - .text_color(rgb(colors::INK_3)) - .child(IconName::Search), - ) - .child( - div() - .flex_1() - .text_size(px(14.0)) - .text_color(rgb(colors::INK_4)) - .child("Search the web or ELY"), - ) - .child( - div() - .size(px(28.0)) - .rounded(px(8.0)) - .bg(rgba(ARROW_CHIP_BG)) - .flex() - .items_center() - .justify_center() - .text_color(rgb(colors::INK_3)) - .child(IconName::ArrowRight), - ) - .into_any_element() -} - -fn render_suggestion_pills(cx: &mut Context) -> AnyElement { - div() - .flex() - .items_center() - .justify_center() - .gap(px(8.0)) - .child(render_pill( - "pill-search-tabs", - IconName::Search, - "Search Tabs", - cx, - |shell, window, cx| shell.focus_address_bar(window, cx), - )) - .child(render_pill( - "pill-switch-workspace", - IconName::LayoutDashboard, - "Switch Workspace", - cx, - |shell, window, cx| shell.cycle_to_next_space(window, cx), - )) - .child(render_pill( - "pill-open-history", - IconName::Undo2, - "Open History", - cx, - |shell, window, cx| shell.open_internal_tab("ely://history", window, cx), - )) - .into_any_element() -} - -fn render_pill( - id: &'static str, - icon: IconName, - label: &'static str, - cx: &mut Context, - handler: F, -) -> AnyElement -where - F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context) + 'static, -{ - div() - .id(SharedString::from(id)) - .rounded(px(999.0)) - .bg(rgba(PILL_BG)) - .shadow(soft_shadow()) - .px(px(12.0)) - .py(px(6.0)) - .flex() - .items_center() - .gap(px(6.0)) - .cursor_pointer() - .hover(|style| style.bg(rgba(PILL_BG_HOVER))) - .active(|style| style.opacity(0.82)) - .on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx))) - .child( - div() - .text_color(rgb(colors::INK_3)) - .text_size(px(12.0)) - .child(icon), - ) - .child( - div() - .text_size(px(12.0)) - .font_weight(FontWeight(500.0)) - .text_color(rgb(colors::INK_2)) - .child(label), - ) - .into_any_element() -} - -fn render_favorites_grid(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { - if snapshot.favorites.is_empty() { - return div().into_any_element(); - } - - div() - .flex() - .flex_col() - .gap(px(12.0)) - .child(render_section_chevron_label("Favorites")) - .child( - div() - .grid() - .grid_cols(7) - .gap(px(10.0)) - .children( - snapshot - .favorites - .iter() - .enumerate() - .map(|(index, tab)| render_favorite_tile(index, tab, cx)), - ) - .child(render_add_favorite_tile(cx)), - ) - .into_any_element() -} - -fn render_section_chevron_label(label: &'static str) -> AnyElement { - div() - .flex() - .items_center() - .gap(px(6.0)) - .text_color(rgb(colors::INK_3)) - .text_size(px(12.5)) - .font_weight(FontWeight(500.0)) - .child(label) - .child( - div() - .text_color(rgb(colors::INK_4)) - .child(IconName::ChevronDown), - ) - .into_any_element() -} - -fn render_favorite_tile( - index: usize, - tab: &BrowserTab, - cx: &mut Context, -) -> AnyElement { - let tab_id = tab.id().clone(); - let initial = tab - .title() - .chars() - .next() - .unwrap_or('?') - .to_uppercase() - .to_string(); - let title = tab.title().to_string(); - - div() - .id(SharedString::from(format!("fav-tile-{index}"))) - .h(px(96.0)) - .rounded(px(16.0)) - .bg(rgba(CARD_BG)) - .shadow(card_shadow()) - .flex() - .flex_col() - .items_center() - .justify_center() - .gap(px(8.0)) - .cursor_pointer() - .hover(|style| style.bg(rgba(CARD_BG_HOVER))) - .active(|style| style.opacity(0.85)) - .on_click(cx.listener(move |shell, _, window, cx| { - shell.select_tab(&tab_id, window, cx); - })) - .child( - div() - .size(px(28.0)) - .rounded(px(7.0)) - .bg(rgb(colors::ACCENT)) - .flex() - .items_center() - .justify_center() - .text_size(px(14.0)) - .font_weight(FontWeight(600.0)) - .text_color(rgb(0xffffff)) - .child(initial), - ) - .child( - div() - .text_size(px(11.5)) - .text_color(rgb(colors::INK_3)) - .max_w(px(80.0)) - .truncate() - .child(title), - ) - .into_any_element() -} - -fn render_add_favorite_tile(cx: &mut Context) -> AnyElement { - div() - .id(SharedString::from("fav-tile-add")) - .h(px(96.0)) - .rounded(px(16.0)) - .bg(rgba(ADD_TILE_BG)) - .flex() - .items_center() - .justify_center() - .text_color(rgb(colors::INK_4)) - .cursor_pointer() - .hover(|style| style.bg(rgba(CARD_BG))) - .on_click(cx.listener(|shell, _, window, cx| { - shell.open_internal_tab("ely://bookmarks", window, cx); - })) - .child(IconName::Plus) - .into_any_element() -} - -fn render_tabs_section(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { - if snapshot.tabs.is_empty() { - return div().into_any_element(); - } - - div() - .flex() - .flex_col() - .gap(px(12.0)) - .child(render_section_chevron_label("Open Tabs")) - .child( - div() - .flex() - .flex_col() - .children(snapshot.tabs.iter().enumerate().map(|(index, tab)| { - render_tab_row(index, tab, &snapshot.active_tab_id, cx) - })), - ) - .into_any_element() -} - -fn render_tab_row( - index: usize, - tab: &BrowserTab, - active_tab_id: &ely_domain::TabId, - cx: &mut Context, -) -> AnyElement { - let tab_id = tab.id().clone(); - let active = tab.id() == active_tab_id; - let bg = if active { CARD_BG_HOVER } else { 0x00000000 }; - - div() - .id(SharedString::from(format!("home-tab-row-{index}"))) - .rounded(px(8.0)) - .px(px(10.0)) - .py(px(8.0)) - .flex() - .items_center() - .gap(px(10.0)) - .cursor_pointer() - .bg(rgba(bg)) - .hover(|style| style.bg(rgba(CARD_BG_HOVER))) - .active(|style| style.opacity(0.82)) - .on_click(cx.listener(move |shell, _, window, cx| { - shell.select_tab(&tab_id, window, cx); - })) - .child( - div() - .text_color(if active { rgb(colors::ACCENT) } else { rgb(colors::INK_4) }) - .child(IconName::Globe), - ) - .child( - div() - .min_w_0() - .flex_1() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_size(px(13.0)) - .font_weight(FontWeight(500.0)) - .text_color(rgb(colors::INK)) - .truncate() - .child(tab.title().to_string()), - ) - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(colors::INK_4)) - .truncate() - .child(tab.display_url()), - ), - ) - .into_any_element() -} - -fn greeting_for_now(now: SystemTime, name: &str) -> String { - let phase = match now.duration_since(UNIX_EPOCH) { - Ok(duration) => { - let local_seconds = duration.as_secs() as i64; - let day_seconds = local_seconds.rem_euclid(86_400); - let hour = (day_seconds / 3_600) as u32; - day_phase(hour) - } - Err(_) => "today", - }; - - if name.is_empty() { - format!("Good {phase}") - } else { - format!("Good {phase}, {name}") - } -} - -fn day_phase(hour_utc: u32) -> &'static str { - match hour_utc { - 5..=11 => "morning", - 12..=17 => "afternoon", - _ => "evening", - } -} - -const SEARCH_BG: u32 = 0xffffffd9; -const ARROW_CHIP_BG: u32 = 0x281e140a; -const PILL_BG: u32 = 0xffffff8c; -const PILL_BG_HOVER: u32 = 0xffffffd9; -const CARD_BG: u32 = 0xffffffc7; -const CARD_BG_HOVER: u32 = 0xffffffeb; -const ADD_TILE_BG: u32 = 0xffffff7f; - -fn card_shadow() -> Vec { - vec![BoxShadow { - color: hsla(25.0 / 360.0, 0.33, 0.12, 0.18), - offset: point(px(0.0), px(8.0)), - blur_radius: px(24.0), - spread_radius: px(-10.0), - }] -} - -fn soft_shadow() -> Vec { - vec![ - BoxShadow { - color: hsla(0.0, 0.0, 1.0, 0.7), - offset: point(px(0.0), px(1.0)), - blur_radius: px(0.0), - spread_radius: px(0.0), - }, - BoxShadow { - color: hsla(25.0 / 360.0, 0.33, 0.12, 0.08), - offset: point(px(0.0), px(0.0)), - blur_radius: px(0.0), - spread_radius: px(1.0), - }, - ] -} - -#[cfg(test)] -mod tests { - use super::{day_phase, greeting_for_now}; - use std::time::{Duration, UNIX_EPOCH}; - - #[test] - fn day_phase_assigns_morning_to_seven_am() { - assert_eq!(day_phase(7), "morning"); - } - - #[test] - fn day_phase_assigns_afternoon_to_one_pm() { - assert_eq!(day_phase(13), "afternoon"); - } - - #[test] - fn day_phase_assigns_evening_to_eleven_pm() { - assert_eq!(day_phase(23), "evening"); - } - - #[test] - fn greeting_includes_profile_name() { - let two_pm = UNIX_EPOCH + Duration::from_secs(14 * 3_600); - assert_eq!(greeting_for_now(two_pm, "Alex"), "Good afternoon, Alex"); - } - - #[test] - fn greeting_omits_name_when_blank() { - let nine_am = UNIX_EPOCH + Duration::from_secs(9 * 3_600); - assert_eq!(greeting_for_now(nine_am, ""), "Good morning"); + render_home_page(snapshot, cx) } }