feat(chrome): animate high-frequency actions

This commit is contained in:
2026-05-16 00:15:33 -04:00
parent 40ab6b4874
commit 42740842b6
7 changed files with 263 additions and 102 deletions
+47 -2
View File
@@ -1,6 +1,11 @@
use std::time::Duration; use std::time::Duration;
use gpui::{Animation, AnimationExt, ElementId, IntoElement, Styled}; use gpui::{Animation, AnimationExt, AnyElement, ElementId, IntoElement, SharedString, Styled};
pub(crate) const MOTION_PRESS_MS: u64 = 120;
pub(crate) const MOTION_SELECTION_MS: u64 = 160;
#[cfg(test)]
pub(crate) const MOTION_FRAME_BUDGET_120HZ: Duration = Duration::from_micros(8_333);
/// One-second blink that toggles between fully visible and invisible at the /// One-second blink that toggles between fully visible and invisible at the
/// half-period mark, matching the design's /// half-period mark, matching the design's
@@ -45,9 +50,42 @@ where
element.with_animation(id, animation, |element, t| element.opacity(ease_out_cubic(t))) element.with_animation(id, animation, |element, t| element.opacity(ease_out_cubic(t)))
} }
pub(crate) fn chrome_motion_feedback<E>(
press_id: Option<SharedString>,
selection_id: impl Into<ElementId>,
selected: bool,
element: E,
) -> AnyElement
where
E: IntoElement + Styled + 'static,
{
if let Some(press_id) = press_id {
return element
.with_animation(
press_id,
Animation::new(Duration::from_millis(MOTION_PRESS_MS)).with_easing(ease_out_cubic),
|element, t| element.opacity(0.84 + 0.16 * t),
)
.into_any_element();
}
if selected {
return element
.with_animation(
selection_id,
Animation::new(Duration::from_millis(MOTION_SELECTION_MS))
.with_easing(ease_out_cubic),
|element, t| element.opacity(0.88 + 0.12 * t),
)
.into_any_element();
}
element.into_any_element()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ease_out_cubic; use super::{MOTION_FRAME_BUDGET_120HZ, MOTION_PRESS_MS, MOTION_SELECTION_MS, ease_out_cubic};
#[test] #[test]
fn ease_out_cubic_anchors_at_endpoints() { fn ease_out_cubic_anchors_at_endpoints() {
@@ -64,4 +102,11 @@ mod tests {
assert!(first_half > 0.5, "ease-out spends more time near 1.0 ({first_half})"); assert!(first_half > 0.5, "ease-out spends more time near 1.0 ({first_half})");
assert!(second_half < 0.5); assert!(second_half < 0.5);
} }
#[test]
fn shell_motion_tokens_fit_120hz_frame_budget() {
assert_eq!(MOTION_FRAME_BUDGET_120HZ.as_micros(), 8_333);
assert!(MOTION_PRESS_MS <= 15 * MOTION_FRAME_BUDGET_120HZ.as_millis() as u64);
assert!(MOTION_SELECTION_MS <= 20 * MOTION_FRAME_BUDGET_120HZ.as_millis() as u64);
}
} }
+72 -31
View File
@@ -9,6 +9,7 @@ use gpui::{
use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; use gpui_component::{IconName, StyledExt, scroll::ScrollableElement};
use crate::shell::ElyShell; use crate::shell::ElyShell;
use crate::shell::chrome::animations::chrome_motion_feedback;
use crate::shell::chrome::sidebar_chrome::{ use crate::shell::chrome::sidebar_chrome::{
ROW_CLOSE_SIZE, active_nav_bg, active_nav_bg_hover, close_hover_bg, highlight_border, ROW_CLOSE_SIZE, active_nav_bg, active_nav_bg_hover, close_hover_bg, highlight_border,
hover_nav_bg, panel_bg, panel_shadow, profile_initial, render_sidebar_resize_handle, hover_nav_bg, panel_bg, panel_shadow, profile_initial, render_sidebar_resize_handle,
@@ -105,8 +106,10 @@ impl ElyShell {
// so it uses hover_nav_bg() directly. Keeping it lighter than the // so it uses hover_nav_bg() directly. Keeping it lighter than the
// active nav card means the eye still finds the active selection // active nav card means the eye still finds the active selection
// first when both are visible. // first when both are visible.
div() let target = "nav-settings";
.id(SharedString::from("nav-settings")) let press_id = self.chrome_motion_animation_id(target);
let element = div()
.id(SharedString::from(target))
.rounded(px(spacing::RADIUS_NAV)) .rounded(px(spacing::RADIUS_NAV))
.px(px(10.0)) .px(px(10.0))
.py(px(7.0)) .py(px(7.0))
@@ -118,12 +121,14 @@ impl ElyShell {
.cursor_pointer() .cursor_pointer()
.hover(|style| style.bg(rgba(hover_nav_bg())).text_color(rgb(colors::ink()))) .hover(|style| style.bg(rgba(hover_nav_bg())).text_color(rgb(colors::ink())))
.active(|style| style.opacity(0.82)) .active(|style| style.opacity(0.82))
.on_click(cx.listener(|shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(target);
shell.open_internal_tab("ely://settings", window, cx); shell.open_internal_tab("ely://settings", window, cx);
})) }))
.child(div().text_color(rgb(colors::ink_3())).child(IconName::Settings)) .child(div().text_color(rgb(colors::ink_3())).child(IconName::Settings))
.child("Settings") .child("Settings");
.into_any_element()
chrome_motion_feedback(press_id, "nav-settings-selection", false, element)
} }
fn render_profile_row( fn render_profile_row(
@@ -133,8 +138,10 @@ impl ElyShell {
) -> AnyElement { ) -> AnyElement {
let profile_name = snapshot.active_profile_name.clone(); let profile_name = snapshot.active_profile_name.clone();
div() let target = "nav-profile";
.id(SharedString::from("nav-profile")) let press_id = self.chrome_motion_animation_id(target);
let element = div()
.id(SharedString::from(target))
.rounded(px(spacing::RADIUS_NAV)) .rounded(px(spacing::RADIUS_NAV))
.px(px(10.0)) .px(px(10.0))
.py(px(6.0)) .py(px(6.0))
@@ -144,7 +151,8 @@ impl ElyShell {
.cursor_pointer() .cursor_pointer()
.hover(|style| style.bg(rgba(hover_nav_bg()))) .hover(|style| style.bg(rgba(hover_nav_bg())))
.active(|style| style.opacity(0.82)) .active(|style| style.opacity(0.82))
.on_click(cx.listener(|shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(target);
shell.open_internal_tab("ely://settings/profiles", window, cx); shell.open_internal_tab("ely://settings/profiles", window, cx);
})) }))
.child( .child(
@@ -183,8 +191,9 @@ impl ElyShell {
// promises "this opens a page" instead of the down // promises "this opens a page" instead of the down
// chevron that promises "this opens a menu inline". // chevron that promises "this opens a menu inline".
div().text_color(rgb(colors::ink_4())).child(IconName::ChevronRight), div().text_color(rgb(colors::ink_4())).child(IconName::ChevronRight),
) );
.into_any_element()
chrome_motion_feedback(press_id, "nav-profile-selection", false, element)
} }
fn render_home_anchor_row( fn render_home_anchor_row(
@@ -195,9 +204,11 @@ impl ElyShell {
let active_tab = snapshot.tabs.iter().find(|tab| tab.id() == &snapshot.active_tab_id); let active_tab = snapshot.tabs.iter().find(|tab| tab.id() == &snapshot.active_tab_id);
let active = active_tab.map(|tab| tab.url().as_str() == "ely://new-tab").unwrap_or(false); let active = active_tab.map(|tab| tab.url().as_str() == "ely://new-tab").unwrap_or(false);
let palette = nav_row_palette(active); let palette = nav_row_palette(active);
let target = "nav-home";
let press_id = self.chrome_motion_animation_id(target);
div() let element = div()
.id(SharedString::from("nav-home")) .id(SharedString::from(target))
.rounded(px(spacing::RADIUS_NAV)) .rounded(px(spacing::RADIUS_NAV))
.px(px(10.0)) .px(px(10.0))
.py(px(7.0)) .py(px(7.0))
@@ -209,7 +220,8 @@ impl ElyShell {
.active(|style| style.opacity(0.82)) .active(|style| style.opacity(0.82))
.bg(rgba(palette.bg)) .bg(rgba(palette.bg))
.when(active, |el| el.shadow(soft_shadow())) .when(active, |el| el.shadow(soft_shadow()))
.on_click(cx.listener(|shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(target);
shell.open_internal_tab("ely://new-tab", window, cx); shell.open_internal_tab("ely://new-tab", window, cx);
})) }))
.child(div().text_color(rgb(colors::ink_3())).child(IconName::Frame)) .child(div().text_color(rgb(colors::ink_3())).child(IconName::Frame))
@@ -220,8 +232,9 @@ impl ElyShell {
.font_weight(FontWeight(500.0)) .font_weight(FontWeight(500.0))
.text_color(rgb(palette.text)) .text_color(rgb(palette.text))
.child("Home"), .child("Home"),
) );
.into_any_element()
chrome_motion_feedback(press_id, "nav-home-selection", active, element)
} }
fn render_launcher_row( fn render_launcher_row(
@@ -237,11 +250,17 @@ impl ElyShell {
let title = tab.title().to_string(); let title = tab.title().to_string();
let initial = title.chars().next().unwrap_or('?').to_string(); let initial = title.chars().next().unwrap_or('?').to_string();
let unread = tab.unread_count(); let unread = tab.unread_count();
let row_id = format!("nav-{}", tab.id().as_str());
let row_motion_target = SharedString::from(row_id.clone());
let row_press_id = self.chrome_motion_animation_id(&row_id);
let row_selection_id = SharedString::from(format!("{row_id}-selection"));
let group_name = SharedString::from(format!("launcher-{}", tab.id().as_str())); let group_name = SharedString::from(format!("launcher-{}", tab.id().as_str()));
let close_id = SharedString::from(format!("launcher-close-{}", tab.id().as_str())); let close_id = SharedString::from(format!("launcher-close-{}", tab.id().as_str()));
let close_motion_target = close_id.clone();
let close_press_id = self.chrome_motion_animation_id(close_id.as_str());
div() let element = div()
.id(SharedString::from(format!("nav-{}", tab.id().as_str()))) .id(SharedString::from(row_id))
.group(group_name.clone()) .group(group_name.clone())
.rounded(px(spacing::RADIUS_NAV)) .rounded(px(spacing::RADIUS_NAV))
.px(px(10.0)) .px(px(10.0))
@@ -255,6 +274,7 @@ impl ElyShell {
.bg(rgba(palette.bg)) .bg(rgba(palette.bg))
.when(active, |el| el.shadow(soft_shadow())) .when(active, |el| el.shadow(soft_shadow()))
.on_click(cx.listener(move |shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(row_motion_target.clone());
shell.select_tab(&tab_id, window, cx); shell.select_tab(&tab_id, window, cx);
})) }))
.child(render_glyph_for(host.as_deref(), &initial, 18.0)) .child(render_glyph_for(host.as_deref(), &initial, 18.0))
@@ -273,17 +293,22 @@ impl ElyShell {
.child(render_row_close_button( .child(render_row_close_button(
close_id, close_id,
group_name, group_name,
close_press_id,
cx.listener(move |shell, _, window, cx| { cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(close_motion_target.clone());
shell.close_tab_by_id(&close_tab_id, window, cx); shell.close_tab_by_id(&close_tab_id, window, cx);
cx.stop_propagation(); cx.stop_propagation();
}), }),
)) ));
.into_any_element()
chrome_motion_feedback(row_press_id, row_selection_id, active, element)
} }
fn render_new_tab_row(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_new_tab_row(&mut self, cx: &mut Context<Self>) -> AnyElement {
div() let target = "nav-new-tab";
.id(SharedString::from("nav-new-tab")) let press_id = self.chrome_motion_animation_id(target);
let element = div()
.id(SharedString::from(target))
.rounded(px(spacing::RADIUS_NAV)) .rounded(px(spacing::RADIUS_NAV))
.px(px(10.0)) .px(px(10.0))
.py(px(7.0)) .py(px(7.0))
@@ -295,12 +320,14 @@ impl ElyShell {
.cursor_pointer() .cursor_pointer()
.hover(|style| style.bg(rgba(hover_nav_bg())).text_color(rgb(colors::ink()))) .hover(|style| style.bg(rgba(hover_nav_bg())).text_color(rgb(colors::ink())))
.active(|style| style.opacity(0.82)) .active(|style| style.opacity(0.82))
.on_click(cx.listener(|shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(target);
shell.open_new_tab(window, cx); shell.open_new_tab(window, cx);
})) }))
.child(div().text_color(rgb(colors::ink_4())).child(IconName::Plus)) .child(div().text_color(rgb(colors::ink_4())).child(IconName::Plus))
.child("New Tab") .child("New Tab");
.into_any_element()
chrome_motion_feedback(press_id, "nav-new-tab-selection", false, element)
} }
pub(crate) fn render_tab_row( pub(crate) fn render_tab_row(
@@ -316,9 +343,15 @@ impl ElyShell {
let close_id = SharedString::from(format!("tab-close-{}", tab.id().as_str())); let close_id = SharedString::from(format!("tab-close-{}", tab.id().as_str()));
let title = tab.title().to_string(); let title = tab.title().to_string();
let initial = title.chars().next().unwrap_or('?').to_string(); let initial = title.chars().next().unwrap_or('?').to_string();
let row_id = tab.id().as_str().to_string();
let row_motion_target = SharedString::from(row_id.clone());
let row_press_id = self.chrome_motion_animation_id(&row_id);
let row_selection_id = SharedString::from(format!("tab-selection-{row_id}"));
let close_motion_target = close_id.clone();
let close_press_id = self.chrome_motion_animation_id(close_id.as_str());
div() let element = div()
.id(SharedString::from(tab.id().as_str().to_string())) .id(SharedString::from(row_id))
.group(group_name.clone()) .group(group_name.clone())
.rounded(px(spacing::RADIUS_NAV)) .rounded(px(spacing::RADIUS_NAV))
.px(px(10.0)) .px(px(10.0))
@@ -332,6 +365,7 @@ impl ElyShell {
.bg(rgba(palette.bg)) .bg(rgba(palette.bg))
.when(active, |el| el.shadow(soft_shadow())) .when(active, |el| el.shadow(soft_shadow()))
.on_click(cx.listener(move |shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(row_motion_target.clone());
shell.select_tab(&tab_id, window, cx); shell.select_tab(&tab_id, window, cx);
})) }))
.child(render_tab_favicon(tab, &initial)) .child(render_tab_favicon(tab, &initial))
@@ -361,12 +395,15 @@ impl ElyShell {
.child(render_row_close_button( .child(render_row_close_button(
close_id, close_id,
group_name, group_name,
close_press_id,
cx.listener(move |shell, _, window, cx| { cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(close_motion_target.clone());
shell.close_tab_by_id(&close_tab_id, window, cx); shell.close_tab_by_id(&close_tab_id, window, cx);
cx.stop_propagation(); cx.stop_propagation();
}), }),
)) ));
.into_any_element()
chrome_motion_feedback(row_press_id, row_selection_id, active, element)
} }
} }
@@ -401,12 +438,14 @@ struct NavRowPalette {
fn render_row_close_button<F>( fn render_row_close_button<F>(
close_id: SharedString, close_id: SharedString,
group_name: SharedString, group_name: SharedString,
press_id: Option<SharedString>,
on_click: F, on_click: F,
) -> impl IntoElement ) -> AnyElement
where where
F: Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, F: Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static,
{ {
div() let selection_id = SharedString::from(format!("{}-selection", close_id.as_str()));
let element = div()
.id(close_id) .id(close_id)
.size(px(ROW_CLOSE_SIZE)) .size(px(ROW_CLOSE_SIZE))
.rounded_full() .rounded_full()
@@ -420,7 +459,9 @@ where
.hover(|style| style.bg(rgba(close_hover_bg())).text_color(rgb(colors::ink()))) .hover(|style| style.bg(rgba(close_hover_bg())).text_color(rgb(colors::ink())))
.cursor_pointer() .cursor_pointer()
.on_click(on_click) .on_click(on_click)
.child(IconName::Close) .child(IconName::Close);
chrome_motion_feedback(press_id, selection_id, false, element)
} }
/// Resolve the favicon glyph for a tab row. Prefers the favicon URL /// Resolve the favicon glyph for a tab row. Prefers the favicon URL
+54 -20
View File
@@ -9,6 +9,7 @@ use gpui::{
use gpui_component::{IconName, input::Input}; use gpui_component::{IconName, input::Input};
use crate::shell::ElyShell; use crate::shell::ElyShell;
use crate::shell::chrome::animations::chrome_motion_feedback;
use crate::shell::sidebar::render_command_bar_identity; use crate::shell::sidebar::render_command_bar_identity;
pub(crate) fn render_topbar( pub(crate) fn render_topbar(
@@ -30,6 +31,7 @@ pub(crate) fn render_topbar(
.border_color(rgba(colors::divider())) .border_color(rgba(colors::divider()))
.when(sidebar_collapsed, |el| el.child(render_command_bar_identity(snapshot, 56.0, true))) .when(sidebar_collapsed, |el| el.child(render_command_bar_identity(snapshot, 56.0, true)))
.child(render_nav_arrow( .child(render_nav_arrow(
shell,
"nav-back", "nav-back",
IconName::ArrowLeft, IconName::ArrowLeft,
active_tab.can_navigate_back(), active_tab.can_navigate_back(),
@@ -37,6 +39,7 @@ pub(crate) fn render_topbar(
|shell, window, cx| shell.navigate_active_tab_back(window, cx), |shell, window, cx| shell.navigate_active_tab_back(window, cx),
)) ))
.child(render_nav_arrow( .child(render_nav_arrow(
shell,
"nav-forward", "nav-forward",
IconName::ArrowRight, IconName::ArrowRight,
active_tab.can_navigate_forward(), active_tab.can_navigate_forward(),
@@ -44,19 +47,24 @@ pub(crate) fn render_topbar(
|shell, window, cx| shell.navigate_active_tab_forward(window, cx), |shell, window, cx| shell.navigate_active_tab_forward(window, cx),
)) ))
.child(render_omnibar(shell, active_tab, window, cx)) .child(render_omnibar(shell, active_tab, window, cx))
.child(render_topbar_action("share-url", IconName::Copy, cx, |shell, window, cx| { .child(render_topbar_action(shell, "share-url", IconName::Copy, cx, |shell, window, cx| {
shell.copy_active_tab_url(window, cx) shell.copy_active_tab_url(window, cx)
})) }))
.child(render_topbar_action("open-downloads", IconName::Folder, cx, |shell, window, cx| {
shell.open_downloads(window, cx)
}))
.child(render_topbar_action( .child(render_topbar_action(
shell,
"open-downloads",
IconName::Folder,
cx,
|shell, window, cx| shell.open_downloads(window, cx),
))
.child(render_topbar_action(
shell,
"toggle-theme", "toggle-theme",
theme_mode_icon(snapshot.appearance.theme_mode()), theme_mode_icon(snapshot.appearance.theme_mode()),
cx, cx,
|shell, _window, cx| shell.cycle_theme_mode(cx), |shell, _window, cx| shell.cycle_theme_mode(cx),
)) ))
.child(render_topbar_action("open-menu", IconName::Menu, cx, |shell, window, cx| { .child(render_topbar_action(shell, "open-menu", IconName::Menu, cx, |shell, window, cx| {
shell.open_internal_tab("ely://settings", window, cx) shell.open_internal_tab("ely://settings", window, cx)
})) }))
.into_any_element() .into_any_element()
@@ -74,6 +82,8 @@ fn render_omnibar(
let command_focused = shell.command_input.read(cx).focus_handle(cx).is_focused(window); let command_focused = shell.command_input.read(cx).focus_handle(cx).is_focused(window);
let show_styled = !command_focused && active_url != "ely://new-tab"; let show_styled = !command_focused && active_url != "ely://new-tab";
let secure = active_url.starts_with("https://") || active_url.starts_with("ely://"); let secure = active_url.starts_with("https://") || active_url.starts_with("ely://");
let omnibar_motion_target = "omnibar-content";
let omnibar_press_id = shell.chrome_motion_animation_id(omnibar_motion_target);
div() div()
.flex_1() .flex_1()
@@ -86,14 +96,18 @@ fn render_omnibar(
.items_center() .items_center()
.gap(px(10.0)) .gap(px(10.0))
.child(render_lock_or_search(secure, show_styled)) .child(render_lock_or_search(secure, show_styled))
.child( .child(chrome_motion_feedback(
omnibar_press_id,
"omnibar-content-selection",
false,
div() div()
.id(SharedString::from("omnibar-content")) .id(SharedString::from("omnibar-content"))
.flex_1() .flex_1()
.min_w_0() .min_w_0()
.cursor_pointer() .cursor_pointer()
.hover(|style| style.opacity(0.92)) .hover(|style| style.opacity(0.92))
.on_click(cx.listener(|shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(omnibar_motion_target);
shell.focus_address_bar(window, cx); shell.focus_address_bar(window, cx);
})) }))
.child(if show_styled { .child(if show_styled {
@@ -101,8 +115,9 @@ fn render_omnibar(
} else { } else {
render_omnibar_input(shell) render_omnibar_input(shell)
}), }),
) ))
.child(render_omnibar_chip( .child(render_omnibar_chip(
shell,
"omnibar-filters", "omnibar-filters",
IconName::Settings2, IconName::Settings2,
false, false,
@@ -110,6 +125,7 @@ fn render_omnibar(
|shell, window, cx| shell.focus_address_bar(window, cx), |shell, window, cx| shell.focus_address_bar(window, cx),
)) ))
.child(render_omnibar_chip( .child(render_omnibar_chip(
shell,
"omnibar-favorite", "omnibar-favorite",
favorite_icon, favorite_icon,
favorite_active, favorite_active,
@@ -149,6 +165,7 @@ fn render_lock_or_search(secure: bool, show_styled: bool) -> AnyElement {
} }
fn render_omnibar_chip<F>( fn render_omnibar_chip<F>(
shell: &ElyShell,
id: &'static str, id: &'static str,
icon: IconName, icon: IconName,
active: bool, active: bool,
@@ -159,7 +176,8 @@ where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static, F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static,
{ {
let color = if active { colors::accent() } else { colors::ink_4() }; let color = if active { colors::accent() } else { colors::ink_4() };
div() let press_id = shell.chrome_motion_animation_id(id);
let element = div()
.id(SharedString::from(id)) .id(SharedString::from(id))
.size(px(22.0)) .size(px(22.0))
.rounded(px(6.0)) .rounded(px(6.0))
@@ -170,12 +188,17 @@ where
.cursor_pointer() .cursor_pointer()
.hover(|style| style.bg(rgba(chip_hover_bg())).text_color(rgb(colors::ink()))) .hover(|style| style.bg(rgba(chip_hover_bg())).text_color(rgb(colors::ink())))
.active(|style| style.opacity(0.7)) .active(|style| style.opacity(0.7))
.on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx))) .on_click(cx.listener(move |shell, _, window, cx| {
.child(icon) shell.trigger_chrome_motion(id);
.into_any_element() handler(shell, window, cx);
}))
.child(icon);
chrome_motion_feedback(press_id, SharedString::from(format!("{id}-selection")), active, element)
} }
fn render_nav_arrow<F>( fn render_nav_arrow<F>(
shell: &ElyShell,
id: &'static str, id: &'static str,
icon: IconName, icon: IconName,
enabled: bool, enabled: bool,
@@ -186,7 +209,8 @@ where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static, F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static,
{ {
let color = if enabled { colors::ink_3() } else { colors::ink_5() }; let color = if enabled { colors::ink_3() } else { colors::ink_5() };
div() let press_id = if enabled { shell.chrome_motion_animation_id(id) } else { None };
let element = div()
.id(SharedString::from(id)) .id(SharedString::from(id))
.size(px(30.0)) .size(px(30.0))
.rounded(px(8.0)) .rounded(px(8.0))
@@ -198,13 +222,18 @@ where
el.cursor_pointer() el.cursor_pointer()
.hover(|style| style.bg(rgba(omnibar_bg())).text_color(rgb(colors::ink()))) .hover(|style| style.bg(rgba(omnibar_bg())).text_color(rgb(colors::ink())))
.active(|style| style.opacity(0.82)) .active(|style| style.opacity(0.82))
.on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx))) .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(id);
handler(shell, window, cx);
}))
}) })
.child(icon) .child(icon);
.into_any_element()
chrome_motion_feedback(press_id, SharedString::from(format!("{id}-selection")), false, element)
} }
fn render_topbar_action<F>( fn render_topbar_action<F>(
shell: &ElyShell,
id: &'static str, id: &'static str,
icon: IconName, icon: IconName,
cx: &mut Context<ElyShell>, cx: &mut Context<ElyShell>,
@@ -213,7 +242,8 @@ fn render_topbar_action<F>(
where where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static, F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static,
{ {
div() let press_id = shell.chrome_motion_animation_id(id);
let element = div()
.id(SharedString::from(id)) .id(SharedString::from(id))
.size(px(30.0)) .size(px(30.0))
.rounded(px(8.0)) .rounded(px(8.0))
@@ -224,9 +254,13 @@ where
.text_color(rgb(colors::ink_3())) .text_color(rgb(colors::ink_3()))
.hover(|style| style.bg(rgba(omnibar_bg())).text_color(rgb(colors::ink()))) .hover(|style| style.bg(rgba(omnibar_bg())).text_color(rgb(colors::ink())))
.active(|style| style.opacity(0.82)) .active(|style| style.opacity(0.82))
.on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx))) .on_click(cx.listener(move |shell, _, window, cx| {
.child(icon) shell.trigger_chrome_motion(id);
.into_any_element() handler(shell, window, cx);
}))
.child(icon);
chrome_motion_feedback(press_id, SharedString::from(format!("{id}-selection")), false, element)
} }
fn omnibar_bg() -> u32 { fn omnibar_bg() -> u32 {
+24
View File
@@ -0,0 +1,24 @@
use gpui::SharedString;
use super::ElyShell;
#[derive(Default)]
pub(crate) struct ChromeMotionState {
target: Option<SharedString>,
epoch: u64,
}
impl ElyShell {
pub(crate) fn trigger_chrome_motion(&mut self, target: impl Into<SharedString>) {
self.chrome_motion.target = Some(target.into());
self.chrome_motion.epoch = self.chrome_motion.epoch.wrapping_add(1);
}
pub(crate) fn chrome_motion_animation_id(&self, target: &str) -> Option<SharedString> {
self.chrome_motion
.target
.as_ref()
.filter(|current| current.as_str() == target)
.map(|_| SharedString::from(format!("{target}-motion-{}", self.chrome_motion.epoch)))
}
}
+3 -30
View File
@@ -3,6 +3,7 @@ mod auth;
mod bookmark_files; mod bookmark_files;
mod bookmarks; mod bookmarks;
pub(crate) mod chrome; pub(crate) mod chrome;
mod chrome_motion;
mod command_actions; mod command_actions;
mod download_targets; mod download_targets;
mod downloads; mod downloads;
@@ -70,6 +71,7 @@ pub struct ElyShell {
pub(crate) workspace_picker_open: bool, pub(crate) workspace_picker_open: bool,
pub(crate) sidebar_hover_expanded: bool, pub(crate) sidebar_hover_expanded: bool,
pub(crate) command_selected_index: usize, pub(crate) command_selected_index: usize,
chrome_motion: chrome_motion::ChromeMotionState,
/// Live state for sidebar-resize drag. While the user holds the /// Live state for sidebar-resize drag. While the user holds the
/// resize handle: `(mouse_x_at_drag_start, sidebar_width_px_at_drag_start)`. /// resize handle: `(mouse_x_at_drag_start, sidebar_width_px_at_drag_start)`.
/// `None` whenever no drag is in flight. /// `None` whenever no drag is in flight.
@@ -202,6 +204,7 @@ impl ElyShell {
workspace_picker_open: false, workspace_picker_open: false,
sidebar_hover_expanded: false, sidebar_hover_expanded: false,
command_selected_index: 0, command_selected_index: 0,
chrome_motion: chrome_motion::ChromeMotionState::default(),
sidebar_resize_origin: None, sidebar_resize_origin: None,
download_action_error: None, download_action_error: None,
download_clear_confirmation: false, download_clear_confirmation: false,
@@ -239,36 +242,6 @@ impl ElyShell {
shell shell
} }
/// Inspect the on-disk bearer token (if any) and seed the
/// `SyncConnectionState` so the Sync settings page reads the right
/// label on first render — without any sync setting page open it
/// would otherwise stay `SignedOut` until the user clicks Sync now.
fn probe_initial_sync_state(&mut self) {
let ShellState::Ready(core) = &mut self.state else {
return;
};
let Some(snapshot) = core.snapshot().ok() else {
return;
};
let active_profile_id = snapshot.active_profile_id.clone();
let Some(profile_root) = crate::services::servo_profile_data::default_profile_data_root()
else {
return;
};
let profile_dir = crate::services::servo_profile_data::profile_data_dir(
&profile_root,
&active_profile_id,
);
let bearer_path = profile_dir.join("sync").join("bearer.token");
let bearer_present = std::fs::metadata(&bearer_path).map(|m| m.len() > 0).unwrap_or(false);
let state = if bearer_present {
ely_domain::SyncConnectionState::SignedIn
} else {
ely_domain::SyncConnectionState::SignedOut
};
core.set_sync_connection_state(state);
}
/// Drain any sync upload outcomes the off-thread worker pushed /// Drain any sync upload outcomes the off-thread worker pushed
/// since the previous tick and stamp the resulting connection /// since the previous tick and stamp the resulting connection
/// state on `BrowserCore`. Returns `true` when at least one /// state on `BrowserCore`. Returns `true` when at least one
+30 -18
View File
@@ -5,15 +5,15 @@ use ely_domain::{
HIDDEN_SIDEBAR_WIDTH_PX, Space, HIDDEN_SIDEBAR_WIDTH_PX, Space,
}; };
use gpui::{ use gpui::{
AnyElement, BoxShadow, Context, IntoElement, ParentElement, Styled, Window, div, hsla, point, AnyElement, BoxShadow, Context, IntoElement, ParentElement, SharedString, Styled, Window, div,
px, rgb, rgba, hsla, point, px, rgb, rgba,
}; };
use gpui_component::{ use gpui_component::{
IconName, Selectable, Sizable, StyledExt, IconName, Selectable, Sizable, StyledExt,
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
}; };
use super::chrome::panel_bg; use super::chrome::{animations::chrome_motion_feedback, panel_bg};
use super::{ElyShell, ShellState, render::tab_profile_label}; use super::{ElyShell, ShellState, render::tab_profile_label};
use crate::ToggleSidebar; use crate::ToggleSidebar;
@@ -167,16 +167,21 @@ impl ElyShell {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let space_id = space.id().clone(); let space_id = space.id().clone();
Button::new(("compact-space", index)) let target = SharedString::from(format!("compact-space-{}", space.id().as_str()));
let press_id = self.chrome_motion_animation_id(target.as_str());
let selection_id = SharedString::from(format!("{}-selection", target.as_str()));
let button = Button::new(("compact-space", index))
.ghost() .ghost()
.small() .small()
.selected(active) .selected(active)
.label(space.icon().to_string()) .label(space.icon().to_string())
.tooltip(space.name().to_string()) .tooltip(space.name().to_string())
.on_click(cx.listener(move |shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(target.clone());
shell.select_space(&space_id, window, cx); shell.select_space(&space_id, window, cx);
})) }));
.into_any_element()
chrome_motion_feedback(press_id, selection_id, active, div().child(button))
} }
fn render_compact_tab_button( fn render_compact_tab_button(
@@ -190,16 +195,18 @@ impl ElyShell {
) -> AnyElement { ) -> AnyElement {
let tab_id = tab.id().clone(); let tab_id = tab.id().clone();
let tooltip = format!("{} - {}", tab.title(), tab_profile_label(tab, &snapshot.profiles)); let tooltip = format!("{} - {}", tab.title(), tab_profile_label(tab, &snapshot.profiles));
Button::new(id) let target = SharedString::from(format!("compact-tab-{}", tab.id().as_str()));
.ghost() let press_id = self.chrome_motion_animation_id(target.as_str());
.small() let selection_id = SharedString::from(format!("{}-selection", target.as_str()));
.selected(active) let button =
.icon(icon) Button::new(id).ghost().small().selected(active).icon(icon).tooltip(tooltip).on_click(
.tooltip(tooltip) cx.listener(move |shell, _, window, cx| {
.on_click(cx.listener(move |shell, _, window, cx| { shell.trigger_chrome_motion(target.clone());
shell.select_tab(&tab_id, window, cx); shell.select_tab(&tab_id, window, cx);
})) }),
.into_any_element() );
chrome_motion_feedback(press_id, selection_id, active, div().child(button))
} }
fn render_compact_archived_button( fn render_compact_archived_button(
@@ -210,15 +217,20 @@ impl ElyShell {
) -> AnyElement { ) -> AnyElement {
let tab = archived_tab.tab(); let tab = archived_tab.tab();
let tab_id = tab.id().clone(); let tab_id = tab.id().clone();
Button::new(("compact-archive", index)) let target = SharedString::from(format!("compact-archive-{}", tab.id().as_str()));
let press_id = self.chrome_motion_animation_id(target.as_str());
let selection_id = SharedString::from(format!("{}-selection", target.as_str()));
let button = Button::new(("compact-archive", index))
.ghost() .ghost()
.small() .small()
.icon(IconName::Undo2) .icon(IconName::Undo2)
.tooltip(tab.title().to_string()) .tooltip(tab.title().to_string())
.on_click(cx.listener(move |shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.trigger_chrome_motion(target.clone());
shell.restore_archived_tab(&tab_id, window, cx); shell.restore_archived_tab(&tab_id, window, cx);
})) }));
.into_any_element()
chrome_motion_feedback(press_id, selection_id, false, div().child(button))
} }
} }
+32
View File
@@ -1,3 +1,5 @@
use super::{ElyShell, ShellState};
/// Messages the off-thread sync workers push back to the shell so /// Messages the off-thread sync workers push back to the shell so
/// `SyncConnectionState` on `BrowserCore` and the in-flight auth /// `SyncConnectionState` on `BrowserCore` and the in-flight auth
/// form reflect live state without the UI thread ever touching the /// form reflect live state without the UI thread ever touching the
@@ -28,3 +30,33 @@ pub(crate) const fn sync_platform_label() -> &'static str {
"other" "other"
} }
} }
impl ElyShell {
/// Inspect the on-disk bearer token and seed `SyncConnectionState`
/// so the Sync settings page reads the startup state on first render.
pub(super) fn probe_initial_sync_state(&mut self) {
let ShellState::Ready(core) = &mut self.state else {
return;
};
let Some(snapshot) = core.snapshot().ok() else {
return;
};
let active_profile_id = snapshot.active_profile_id.clone();
let Some(profile_root) = crate::services::servo_profile_data::default_profile_data_root()
else {
return;
};
let profile_dir = crate::services::servo_profile_data::profile_data_dir(
&profile_root,
&active_profile_id,
);
let bearer_path = profile_dir.join("sync").join("bearer.token");
let bearer_present = std::fs::metadata(&bearer_path).map(|m| m.len() > 0).unwrap_or(false);
let state = if bearer_present {
ely_domain::SyncConnectionState::SignedIn
} else {
ely_domain::SyncConnectionState::SignedOut
};
core.set_sync_connection_state(state);
}
}