579 lines
21 KiB
Rust
579 lines
21 KiB
Rust
use super::OnboardingSlide;
|
|
use crate::model::OnboardingStateModel;
|
|
use crate::slides::{bottom_nav, layout, slide_content};
|
|
use crate::telemetry::OnboardingEvent;
|
|
use crate::OnboardingIntention;
|
|
use galaxy_core::send_telemetry_from_ctx;
|
|
use galaxy_core::ui::theme::Fill;
|
|
use galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
|
|
use galaxyui::prelude::Align;
|
|
use galaxyui::{
|
|
elements::{
|
|
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
|
|
CrossAxisAlignment, DropShadow, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
|
|
MainAxisSize, MouseStateHandle, ParentElement, Radius, SizeConstraintCondition,
|
|
SizeConstraintSwitch,
|
|
},
|
|
fonts::Weight,
|
|
keymap::Keystroke,
|
|
platform::Cursor,
|
|
text_layout::TextAlignment,
|
|
ui_components::components::{UiComponent as _, UiComponentStyles},
|
|
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
|
|
ViewContext,
|
|
};
|
|
use ui_components::{button, Component as _, Options as _};
|
|
|
|
const SUBSCRIBE_ITEMS: &[&str] = &[
|
|
"1,500 credits per month",
|
|
"Access to frontier OpenAI, Anthropic, and Google models",
|
|
"Access to Reload credits and volume-based discounts",
|
|
"Extended cloud agents access",
|
|
"Highest codebase indexing limits",
|
|
"Unlimited Warp Drive objects and collaboration",
|
|
"Private email support",
|
|
"Unlimited cloud conversation storage",
|
|
];
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum FreeUserNoAiSlideAction {
|
|
SelectAgent,
|
|
SelectTerminal,
|
|
BackClicked,
|
|
NextClicked,
|
|
UpgradeClicked,
|
|
}
|
|
|
|
pub struct FreeUserNoAiSlide {
|
|
onboarding_state: ModelHandle<OnboardingStateModel>,
|
|
agent_mouse_state: MouseStateHandle,
|
|
classic_terminal_mouse_state: MouseStateHandle,
|
|
subscribe_panel_mouse_state: MouseStateHandle,
|
|
back_button: button::Button,
|
|
next_button: button::Button,
|
|
subscribe_nav_button: button::Button,
|
|
scroll_state: ClippedScrollStateHandle,
|
|
}
|
|
|
|
impl FreeUserNoAiSlide {
|
|
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
|
|
Self {
|
|
onboarding_state,
|
|
agent_mouse_state: MouseStateHandle::default(),
|
|
classic_terminal_mouse_state: MouseStateHandle::default(),
|
|
subscribe_panel_mouse_state: MouseStateHandle::default(),
|
|
back_button: button::Button::default(),
|
|
next_button: button::Button::default(),
|
|
subscribe_nav_button: button::Button::default(),
|
|
scroll_state: ClippedScrollStateHandle::new(),
|
|
}
|
|
}
|
|
|
|
fn model_intention(&self, app: &AppContext) -> OnboardingIntention {
|
|
*self.onboarding_state.as_ref(app).intention()
|
|
}
|
|
|
|
fn render_content(
|
|
&self,
|
|
appearance: &Appearance,
|
|
selected_index: usize,
|
|
// i.e. when window is small, the "subscribe" button replaces the "next" button instead of just
|
|
// living on the CTA in the right pane
|
|
subscribe_in_nav: bool,
|
|
agent_price_badge: &str,
|
|
) -> Box<dyn Element> {
|
|
let bottom_nav =
|
|
Align::new(self.render_bottom_nav(appearance, selected_index, subscribe_in_nav))
|
|
.finish();
|
|
|
|
slide_content::onboarding_slide_content(
|
|
vec![
|
|
Align::new(self.render_header(appearance)).left().finish(),
|
|
Align::new(self.render_options(appearance, selected_index, agent_price_badge))
|
|
.finish(),
|
|
],
|
|
bottom_nav,
|
|
self.scroll_state.clone(),
|
|
appearance,
|
|
)
|
|
}
|
|
|
|
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
appearance
|
|
.ui_builder()
|
|
.paragraph("Let's get started.")
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(36.),
|
|
font_weight: Some(Weight::Medium),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish()
|
|
}
|
|
|
|
fn render_options(
|
|
&self,
|
|
appearance: &Appearance,
|
|
selected_index: usize,
|
|
agent_price_badge: &str,
|
|
) -> Box<dyn Element> {
|
|
let agent_button = self.render_option_button(
|
|
appearance,
|
|
0,
|
|
Icon::Code2,
|
|
"Agent driven development with Warp's built-in agent",
|
|
"Iterate, plan, and build with Oz: Warp's built-in agent. Available locally or in the cloud.",
|
|
agent_price_badge.to_string(),
|
|
true, // badge is green
|
|
self.agent_mouse_state.clone(),
|
|
selected_index,
|
|
FreeUserNoAiSlideAction::SelectAgent,
|
|
);
|
|
let terminal_button = self.render_option_button(
|
|
appearance,
|
|
1,
|
|
Icon::Terminal,
|
|
"Classic terminal with third-party agents",
|
|
"A modern terminal that supports third-party agents (Claude Code, Codex, Gemini CLI) and classic terminal workflows.",
|
|
"Free".to_string(),
|
|
false, // badge is gray
|
|
self.classic_terminal_mouse_state.clone(),
|
|
selected_index,
|
|
FreeUserNoAiSlideAction::SelectTerminal,
|
|
);
|
|
|
|
Container::new(
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_child(
|
|
Container::new(agent_button)
|
|
.with_margin_bottom(12.)
|
|
.finish(),
|
|
)
|
|
.with_child(terminal_button)
|
|
.finish(),
|
|
)
|
|
.with_margin_top(32.)
|
|
.finish()
|
|
}
|
|
|
|
fn render_badge(
|
|
&self,
|
|
appearance: &Appearance,
|
|
text: String,
|
|
text_color: galaxyui::color::ColorU,
|
|
border_color: Fill,
|
|
) -> Box<dyn Element> {
|
|
let label = appearance
|
|
.ui_builder()
|
|
.paragraph(text)
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(11.),
|
|
font_weight: Some(Weight::Normal),
|
|
font_color: Some(text_color),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish();
|
|
|
|
Container::new(label)
|
|
.with_horizontal_padding(8.)
|
|
.with_vertical_padding(3.)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
|
.with_border(Border::all(1.).with_border_fill(border_color))
|
|
.finish()
|
|
}
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render_option_button(
|
|
&self,
|
|
appearance: &Appearance,
|
|
index: usize,
|
|
icon: Icon,
|
|
label: &'static str,
|
|
description: &'static str,
|
|
badge_text: String,
|
|
badge_green: bool,
|
|
mouse_state: MouseStateHandle,
|
|
selected_index: usize,
|
|
action: FreeUserNoAiSlideAction,
|
|
) -> Box<dyn Element> {
|
|
const RADIUS: f32 = 8.;
|
|
|
|
let theme = appearance.theme();
|
|
let is_selected = selected_index == index;
|
|
|
|
let text_fill = if is_selected {
|
|
internal_colors::accent_fg_strong(theme)
|
|
} else {
|
|
internal_colors::text_sub(theme, theme.background().into_solid()).into()
|
|
};
|
|
let text_color = text_fill.into_solid();
|
|
let background = is_selected.then(|| internal_colors::accent_overlay_1(theme));
|
|
let border_color = if is_selected {
|
|
theme.accent()
|
|
} else {
|
|
Fill::Solid(internal_colors::neutral_4(theme))
|
|
};
|
|
let ui_font_family = appearance.ui_font_family();
|
|
|
|
// For green badges, always use green. For others ("Free"), follow the
|
|
// selected state so the chip looks active when the option is selected.
|
|
let badge_color = if badge_green {
|
|
theme.ansi_fg_green()
|
|
} else {
|
|
text_fill.into_solid()
|
|
};
|
|
let badge = self.render_badge(
|
|
appearance,
|
|
badge_text,
|
|
badge_color,
|
|
Fill::Solid(badge_color),
|
|
);
|
|
|
|
Hoverable::new(mouse_state, move |_| {
|
|
let icon_el = ConstrainedBox::new(icon.to_galaxyui_icon(text_fill).finish())
|
|
.with_width(20.)
|
|
.with_height(20.)
|
|
.finish();
|
|
|
|
let top_row = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(icon_el)
|
|
.with_child(badge)
|
|
.finish();
|
|
|
|
let label_el = appearance
|
|
.ui_builder()
|
|
.paragraph(label)
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(14.),
|
|
font_weight: Some(Weight::Normal),
|
|
font_color: Some(text_color),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish();
|
|
|
|
let description_el = FormattedTextElement::from_str(description, ui_font_family, 12.)
|
|
.with_color(text_color)
|
|
.with_weight(Weight::Normal)
|
|
.with_alignment(TextAlignment::Left)
|
|
.with_line_height_ratio(1.4)
|
|
.finish();
|
|
|
|
let content = Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_main_axis_alignment(MainAxisAlignment::Start)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(top_row)
|
|
.with_child(Container::new(label_el).with_margin_top(8.).finish())
|
|
.with_child(Container::new(description_el).with_margin_top(4.).finish())
|
|
.finish();
|
|
|
|
let mut container = Container::new(content)
|
|
.with_uniform_padding(16.)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(RADIUS)))
|
|
.with_border(Border::all(1.).with_border_fill(border_color));
|
|
|
|
if let Some(bg) = background {
|
|
container = container.with_background(bg);
|
|
}
|
|
|
|
container.finish()
|
|
})
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
})
|
|
.finish()
|
|
}
|
|
|
|
fn render_bottom_nav(
|
|
&self,
|
|
appearance: &Appearance,
|
|
selected_index: usize,
|
|
subscribe_in_nav: bool,
|
|
) -> Box<dyn Element> {
|
|
let back_button = self.back_button.render(
|
|
appearance,
|
|
button::Params {
|
|
content: button::Content::Label("Back".into()),
|
|
theme: &button::themes::Naked,
|
|
options: button::Options {
|
|
on_click: Some(Box::new(|ctx, _app, _pos| {
|
|
ctx.dispatch_typed_action(FreeUserNoAiSlideAction::BackClicked);
|
|
})),
|
|
..button::Options::default(appearance)
|
|
},
|
|
},
|
|
);
|
|
|
|
let enter = Keystroke::parse("enter").unwrap_or_default();
|
|
let next_button = if selected_index == 1 {
|
|
self.next_button.render(
|
|
appearance,
|
|
button::Params {
|
|
content: button::Content::Label("Get Warping".into()),
|
|
theme: &button::themes::Primary,
|
|
options: button::Options {
|
|
keystroke: Some(enter),
|
|
on_click: Some(Box::new(|ctx, _app, _pos| {
|
|
ctx.dispatch_typed_action(FreeUserNoAiSlideAction::NextClicked);
|
|
})),
|
|
..button::Options::default(appearance)
|
|
},
|
|
},
|
|
)
|
|
} else if subscribe_in_nav {
|
|
self.subscribe_nav_button.render(
|
|
appearance,
|
|
button::Params {
|
|
content: button::Content::Label("Subscribe".into()),
|
|
theme: &button::themes::Primary,
|
|
options: button::Options {
|
|
on_click: Some(Box::new(|ctx, _app, _pos| {
|
|
ctx.dispatch_typed_action(FreeUserNoAiSlideAction::UpgradeClicked);
|
|
})),
|
|
..button::Options::default(appearance)
|
|
},
|
|
},
|
|
)
|
|
} else {
|
|
self.next_button.render(
|
|
appearance,
|
|
button::Params {
|
|
content: button::Content::Label("Next".into()),
|
|
theme: &button::themes::Primary,
|
|
options: button::Options {
|
|
disabled: true,
|
|
keystroke: Some(enter),
|
|
..button::Options::default(appearance)
|
|
},
|
|
},
|
|
)
|
|
};
|
|
|
|
bottom_nav::onboarding_bottom_nav(appearance, 1, 4, Some(back_button), Some(next_button))
|
|
}
|
|
|
|
fn render_subscribe_panel(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let ui_font_family = appearance.ui_font_family();
|
|
|
|
let card_bg = theme.surface_2();
|
|
let text_main = internal_colors::text_main(theme, internal_colors::neutral_2(theme));
|
|
let text_sub = internal_colors::text_sub(theme, internal_colors::neutral_2(theme));
|
|
|
|
let title = FormattedTextElement::from_str(
|
|
"Subscribe to access agent driven development in Warp.",
|
|
ui_font_family,
|
|
24.,
|
|
)
|
|
.with_color(text_main)
|
|
.with_weight(Weight::Medium)
|
|
.with_alignment(TextAlignment::Left)
|
|
.with_line_height_ratio(1.3)
|
|
.finish();
|
|
|
|
let mut items_col = Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_spacing(8.);
|
|
|
|
for item_text in SUBSCRIBE_ITEMS {
|
|
let bullet = appearance
|
|
.ui_builder()
|
|
.paragraph(format!("\u{2022} {item_text}"))
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(14.),
|
|
font_weight: Some(Weight::Normal),
|
|
font_color: Some(text_sub),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish();
|
|
items_col = items_col.with_child(bullet);
|
|
}
|
|
|
|
let fg_color = theme.foreground().into_solid();
|
|
let subscribe_btn = Hoverable::new(
|
|
self.subscribe_panel_mouse_state.clone(),
|
|
move |mouse_state| {
|
|
let bg = if mouse_state.is_clicked() {
|
|
internal_colors::accent_overlay_3(theme)
|
|
} else if mouse_state.is_hovered() {
|
|
internal_colors::accent_overlay_4(theme)
|
|
} else {
|
|
theme.accent()
|
|
};
|
|
|
|
let label = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(
|
|
galaxyui::elements::Text::new_inline(
|
|
"Subscribe",
|
|
appearance.ui_font_family(),
|
|
14.,
|
|
)
|
|
.with_color(fg_color)
|
|
.with_style(galaxyui::fonts::Properties {
|
|
weight: Weight::Semibold,
|
|
style: galaxyui::fonts::Style::Normal,
|
|
})
|
|
.with_selectable(false)
|
|
.finish(),
|
|
)
|
|
.finish();
|
|
|
|
ConstrainedBox::new(
|
|
Container::new(label)
|
|
.with_horizontal_padding(12.)
|
|
.with_background(bg)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
|
.finish(),
|
|
)
|
|
.with_height(32.)
|
|
.finish()
|
|
},
|
|
)
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(FreeUserNoAiSlideAction::UpgradeClicked);
|
|
})
|
|
.finish();
|
|
|
|
let card_content = Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_child(title)
|
|
.with_child(
|
|
Container::new(items_col.finish())
|
|
.with_margin_top(20.)
|
|
.finish(),
|
|
)
|
|
.with_child(Container::new(subscribe_btn).with_margin_top(24.).finish())
|
|
.finish();
|
|
|
|
let card = Container::new(card_content)
|
|
.with_uniform_padding(28.)
|
|
.with_background(card_bg)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(12.)))
|
|
.with_drop_shadow(DropShadow::default())
|
|
.finish();
|
|
|
|
Container::new(Align::new(ConstrainedBox::new(card).with_max_width(420.).finish()).finish())
|
|
.with_background(theme.surface_1())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Entity for FreeUserNoAiSlide {
|
|
type Event = ();
|
|
}
|
|
|
|
impl View for FreeUserNoAiSlide {
|
|
fn ui_name() -> &'static str {
|
|
"FreeUserNoAiSlide"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
let intention = self.model_intention(app);
|
|
let agent_price_badge = self.onboarding_state.as_ref(app).agent_price_badge();
|
|
let agent_price_badge = agent_price_badge.as_str();
|
|
|
|
let selected_index = match intention {
|
|
OnboardingIntention::AgentDrivenDevelopment => 0,
|
|
OnboardingIntention::Terminal => 1,
|
|
};
|
|
|
|
// Wide (right panel visible): greyed-out Next in nav.
|
|
// Narrow (right panel hidden): Subscribe in nav (the only CTA visible).
|
|
let wide = layout::static_left(
|
|
|| self.render_content(appearance, selected_index, false, agent_price_badge),
|
|
|| self.render_subscribe_panel(appearance),
|
|
);
|
|
let narrow = layout::static_left(
|
|
|| self.render_content(appearance, selected_index, true, agent_price_badge),
|
|
|| self.render_subscribe_panel(appearance),
|
|
);
|
|
|
|
SizeConstraintSwitch::new(
|
|
wide,
|
|
vec![(
|
|
SizeConstraintCondition::WidthLessThan(layout::TWO_COLUMN_MIN_WIDTH),
|
|
narrow,
|
|
)],
|
|
)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl OnboardingSlide for FreeUserNoAiSlide {
|
|
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let intention = self.model_intention(ctx);
|
|
if matches!(intention, OnboardingIntention::Terminal) {
|
|
self.onboarding_state
|
|
.update(ctx, |model, ctx| model.complete(ctx));
|
|
}
|
|
}
|
|
|
|
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let intention = self.model_intention(ctx);
|
|
if matches!(intention, OnboardingIntention::Terminal) {
|
|
self.onboarding_state.update(ctx, |model, ctx| {
|
|
model.set_intention_agent_driven_development(ctx)
|
|
});
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let intention = self.model_intention(ctx);
|
|
if matches!(intention, OnboardingIntention::AgentDrivenDevelopment) {
|
|
self.onboarding_state
|
|
.update(ctx, |model, ctx| model.set_intention_terminal(ctx));
|
|
ctx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TypedActionView for FreeUserNoAiSlide {
|
|
type Action = FreeUserNoAiSlideAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
FreeUserNoAiSlideAction::SelectAgent => {
|
|
self.onboarding_state.update(ctx, |model, ctx| {
|
|
model.set_intention_agent_driven_development(ctx);
|
|
});
|
|
ctx.notify();
|
|
}
|
|
FreeUserNoAiSlideAction::SelectTerminal => {
|
|
self.onboarding_state.update(ctx, |model, ctx| {
|
|
model.set_intention_terminal(ctx);
|
|
});
|
|
ctx.notify();
|
|
}
|
|
FreeUserNoAiSlideAction::BackClicked => {
|
|
self.onboarding_state
|
|
.update(ctx, |model, ctx| model.back(ctx));
|
|
}
|
|
FreeUserNoAiSlideAction::NextClicked => {
|
|
self.onboarding_state
|
|
.update(ctx, |model, ctx| model.complete(ctx));
|
|
}
|
|
FreeUserNoAiSlideAction::UpgradeClicked => {
|
|
send_telemetry_from_ctx!(OnboardingEvent::FreeUserNoAiUpgradeClicked, ctx);
|
|
self.onboarding_state
|
|
.update(ctx, |model, ctx| model.request_upgrade(ctx));
|
|
}
|
|
}
|
|
}
|
|
}
|