Add Continue + Recent activity recap to home

Home now ends with the design's two-card recap row: a "Continue where
you left off" card on the left (history list + reading-list cover that
opens the latest item, falling back to the reading list page when the
list is empty) and a "Recent activity" card on the right that shows
visited entries with relative time labels and a "View all history" link.

The previous open-tabs list is dropped from home — the sidebar already
exposes tabs and the design home doesn't repeat them. Source data is
real history_entries / reading_list off the snapshot; nothing is
fabricated. The home page is split into chrome/home/{hero, favorites,
recap, section, style, time}.rs so each file stays under 500 lines, and
internal_pages/new_tab.rs is now a thin shim that calls into it.
This commit is contained in:
2026-05-09 18:06:18 -04:00
parent 76eff3cf6e
commit 4d44057367
9 changed files with 870 additions and 468 deletions
@@ -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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<ElyShell>) -> 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()
}
@@ -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<ElyShell>) -> 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<ElyShell>) -> 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<ElyShell>) -> 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<F>(
id: &'static str,
icon: IconName,
label: &'static str,
cx: &mut Context<ElyShell>,
handler: F,
) -> AnyElement
where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + '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()
}
@@ -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<ElyShell>,
) -> 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()
}
@@ -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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<ElyShell>,
) -> 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<ElyShell>) -> 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;
@@ -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()
}
@@ -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<BoxShadow> {
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<BoxShadow> {
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),
},
]
}
@@ -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");
}
}
+2
View File
@@ -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};
@@ -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<Self>,
) -> 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<ElyShell>) -> 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<ElyShell>) -> 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<ElyShell>) -> 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<F>(
id: &'static str,
icon: IconName,
label: &'static str,
cx: &mut Context<ElyShell>,
handler: F,
) -> AnyElement
where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + '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<ElyShell>) -> 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<ElyShell>,
) -> 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<ElyShell>) -> 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<ElyShell>) -> 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<ElyShell>,
) -> 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<BoxShadow> {
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<BoxShadow> {
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)
}
}