Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
use crate::model::{
|
||||
OnboardingAuthState, OnboardingStateEvent, OnboardingStateModel, OnboardingStep,
|
||||
SelectedSettings,
|
||||
};
|
||||
use crate::slides::{
|
||||
AgentSlide, AgentSlideEvent, CustomizeUISlide, FreeUserNoAiSlide, IntentionSlide, IntroSlide,
|
||||
IntroSlideEvent, OnboardingModelInfo, OnboardingSlide, ProjectSlide, ThemePickerSlide,
|
||||
ThemePickerSlideEvent, ThirdPartySlide,
|
||||
};
|
||||
use crate::telemetry::OnboardingEvent;
|
||||
use ai::LLMId;
|
||||
use instant::Instant;
|
||||
use std::time::Duration;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::image_cache::ImageType;
|
||||
use warpui::windowing::{
|
||||
state::{ApplicationStage, StateEvent},
|
||||
WindowManager,
|
||||
};
|
||||
|
||||
const APP_BECAME_ACTIVE_DEBOUNCE: Duration = Duration::from_secs(15);
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
use warp_core::ui::{appearance::Appearance, theme::WarpTheme};
|
||||
use warpui::elements::Rect;
|
||||
use warpui::{
|
||||
elements::{
|
||||
CacheOption, ChildAnchor, Container, Empty, Image, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Shrinkable, Stack,
|
||||
},
|
||||
keymap::Keystroke,
|
||||
keymap::{macros::*, FixedBinding},
|
||||
presenter::ChildView,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AgentOnboardingEvent {
|
||||
ThemeSelected {
|
||||
theme_name: String,
|
||||
},
|
||||
SyncWithOsToggled {
|
||||
enabled: bool,
|
||||
},
|
||||
OnboardingCompleted(SelectedSettings),
|
||||
OnboardingSkipped,
|
||||
LoginFromWelcomeRequested,
|
||||
/// Emitted when the user clicks the "Privacy Settings" link on the terminal
|
||||
/// intention theme slide. The variant name encodes that the event is only
|
||||
/// emitted from the terminal-intention theme slide; consumers (e.g. a
|
||||
/// `LoginSlideView` with `LoginSlideSource::PrivacySettingsFromTerminalIntentionTheme`)
|
||||
/// rely on that to select the right visual / back-routing behavior.
|
||||
PrivacySettingsFromTerminalThemeSlideRequested,
|
||||
UpgradeRequested,
|
||||
UpgradeCopyUrlRequested,
|
||||
UpgradePasteTokenFromClipboardRequested,
|
||||
/// Emitted when the app regains focus (e.g. user returns from the browser).
|
||||
/// The parent should refresh any stale data: available models, workspace/billing metadata, etc.
|
||||
AppBecameActive,
|
||||
}
|
||||
|
||||
pub struct AgentOnboardingView {
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
intro_slide: ViewHandle<IntroSlide>,
|
||||
theme_picker_slide: ViewHandle<ThemePickerSlide>,
|
||||
intention_slide: ViewHandle<IntentionSlide>,
|
||||
customize_slide: ViewHandle<CustomizeUISlide>,
|
||||
free_user_no_ai_slide: ViewHandle<FreeUserNoAiSlide>,
|
||||
agent_slide: ViewHandle<AgentSlide>,
|
||||
third_party_slide: ViewHandle<ThirdPartySlide>,
|
||||
project_slide: ViewHandle<ProjectSlide>,
|
||||
skippable: bool,
|
||||
close_button: button::Button,
|
||||
last_model_refresh: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum AgentOnboardingAction {
|
||||
UpKey,
|
||||
DownKey,
|
||||
LeftKey,
|
||||
RightKey,
|
||||
TabKey,
|
||||
EnterKey,
|
||||
CmdOrCtrlEnterKey,
|
||||
Escape,
|
||||
}
|
||||
|
||||
fn dispatch_onboarding_action_to_slide<V: OnboardingSlide>(
|
||||
slide: &mut V,
|
||||
action: AgentOnboardingAction,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
match action {
|
||||
AgentOnboardingAction::UpKey => slide.on_up(ctx),
|
||||
AgentOnboardingAction::DownKey => slide.on_down(ctx),
|
||||
AgentOnboardingAction::LeftKey => slide.on_left(ctx),
|
||||
AgentOnboardingAction::RightKey => slide.on_right(ctx),
|
||||
AgentOnboardingAction::TabKey => slide.on_tab(ctx),
|
||||
AgentOnboardingAction::EnterKey => slide.on_enter(ctx),
|
||||
AgentOnboardingAction::CmdOrCtrlEnterKey => slide.on_cmd_or_ctrl_enter(ctx),
|
||||
AgentOnboardingAction::Escape => slide.on_escape(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentOnboardingView {
|
||||
/// Creates a new AgentOnboardingView.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
theme_picker_themes: [WarpTheme; 4],
|
||||
skippable: bool,
|
||||
models: Vec<OnboardingModelInfo>,
|
||||
default_model_id: LLMId,
|
||||
workspace_enforces_autonomy: bool,
|
||||
agent_modality_enabled: bool,
|
||||
free_user_no_ai_experiment: bool,
|
||||
agent_price_cents: Option<i32>,
|
||||
auth_state: OnboardingAuthState,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let onboarding_state = ctx.add_model(|_| {
|
||||
OnboardingStateModel::new(
|
||||
models,
|
||||
default_model_id,
|
||||
workspace_enforces_autonomy,
|
||||
agent_modality_enabled,
|
||||
free_user_no_ai_experiment,
|
||||
agent_price_cents,
|
||||
auth_state,
|
||||
)
|
||||
});
|
||||
ctx.subscribe_to_model(&onboarding_state, |me, _model, event, ctx| {
|
||||
// Re-render when slide selection changes.
|
||||
if !ctx.is_self_or_child_focused() {
|
||||
ctx.focus_self();
|
||||
}
|
||||
ctx.notify();
|
||||
|
||||
match event {
|
||||
OnboardingStateEvent::Completed => {
|
||||
me.handle_onboarding_completed(ctx);
|
||||
}
|
||||
OnboardingStateEvent::UpgradeRequested => {
|
||||
ctx.emit(AgentOnboardingEvent::UpgradeRequested);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
let intro_slide = {
|
||||
let onboarding_state = onboarding_state.clone();
|
||||
ctx.add_typed_action_view(move |_| IntroSlide::new(onboarding_state))
|
||||
};
|
||||
|
||||
ctx.subscribe_to_view(&intro_slide, |_me, _view, event, ctx| match event {
|
||||
IntroSlideEvent::LoginRequested => {
|
||||
ctx.emit(AgentOnboardingEvent::LoginFromWelcomeRequested);
|
||||
}
|
||||
});
|
||||
|
||||
let theme_picker_slide = {
|
||||
let themes = theme_picker_themes.clone();
|
||||
let onboarding_state = onboarding_state.clone();
|
||||
ctx.add_typed_action_view(move |ctx| {
|
||||
ThemePickerSlide::new(themes.clone(), onboarding_state, ctx)
|
||||
})
|
||||
};
|
||||
|
||||
let intention_slide = {
|
||||
let onboarding_state = onboarding_state.clone();
|
||||
ctx.add_typed_action_view(move |_| IntentionSlide::new(onboarding_state))
|
||||
};
|
||||
|
||||
let customize_slide = {
|
||||
let onboarding_state = onboarding_state.clone();
|
||||
ctx.add_typed_action_view(move |ctx| CustomizeUISlide::new(onboarding_state, ctx))
|
||||
};
|
||||
let free_user_no_ai_slide = {
|
||||
let onboarding_state = onboarding_state.clone();
|
||||
ctx.add_typed_action_view(move |_| FreeUserNoAiSlide::new(onboarding_state))
|
||||
};
|
||||
|
||||
ctx.subscribe_to_view(&theme_picker_slide, |me, _view, event, ctx| {
|
||||
me.handle_theme_picker_slide_event(event, ctx);
|
||||
});
|
||||
|
||||
let agent_slide = {
|
||||
let onboarding_state = onboarding_state.clone();
|
||||
ctx.add_typed_action_view(move |ctx| AgentSlide::new(onboarding_state, ctx))
|
||||
};
|
||||
|
||||
ctx.subscribe_to_view(&agent_slide, |_me, _view, event, ctx| match event {
|
||||
AgentSlideEvent::CopyUpgradeUrlRequested => {
|
||||
ctx.emit(AgentOnboardingEvent::UpgradeCopyUrlRequested);
|
||||
}
|
||||
AgentSlideEvent::PasteAuthTokenFromClipboardRequested => {
|
||||
ctx.emit(AgentOnboardingEvent::UpgradePasteTokenFromClipboardRequested);
|
||||
}
|
||||
});
|
||||
|
||||
let third_party_slide = {
|
||||
let onboarding_state = onboarding_state.clone();
|
||||
ctx.add_typed_action_view(move |ctx| ThirdPartySlide::new(onboarding_state, ctx))
|
||||
};
|
||||
|
||||
let project_slide = {
|
||||
let onboarding_state = onboarding_state.clone();
|
||||
ctx.add_typed_action_view(move |_| ProjectSlide::new(onboarding_state))
|
||||
};
|
||||
|
||||
// When the app regains focus (e.g. user returning from the upgrade page in the
|
||||
// browser), notify the parent to refresh models and workspace/billing metadata.
|
||||
// Debounced to avoid excessive API calls from rapid alt-tabbing.
|
||||
ctx.subscribe_to_model(&WindowManager::handle(ctx), |me, _wm, event, ctx| {
|
||||
let StateEvent::ValueChanged { current, previous } = event;
|
||||
if previous.stage != ApplicationStage::Active
|
||||
&& current.stage == ApplicationStage::Active
|
||||
{
|
||||
let now = Instant::now();
|
||||
let should_refresh = me
|
||||
.last_model_refresh
|
||||
.is_none_or(|last| now.duration_since(last) >= APP_BECAME_ACTIVE_DEBOUNCE);
|
||||
if should_refresh {
|
||||
me.last_model_refresh = Some(now);
|
||||
ctx.emit(AgentOnboardingEvent::AppBecameActive);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
onboarding_state,
|
||||
intro_slide,
|
||||
theme_picker_slide,
|
||||
intention_slide,
|
||||
customize_slide,
|
||||
free_user_no_ai_slide,
|
||||
agent_slide,
|
||||
third_party_slide,
|
||||
project_slide,
|
||||
skippable,
|
||||
close_button: button::Button::default(),
|
||||
last_model_refresh: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the list of available models.
|
||||
pub fn set_onboarding_models(
|
||||
&mut self,
|
||||
models: Vec<OnboardingModelInfo>,
|
||||
default_model_id: LLMId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.onboarding_state.update(ctx, |state, ctx| {
|
||||
state.set_models(models, default_model_id, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn set_workspace_enforces_autonomy(&mut self, value: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |state, ctx| {
|
||||
state.set_workspace_enforces_autonomy(value, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn set_auth_state(&mut self, auth_state: OnboardingAuthState, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |state, ctx| {
|
||||
state.set_auth_state(auth_state, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn free_user_no_ai_experiment(&self, ctx: &AppContext) -> bool {
|
||||
self.onboarding_state
|
||||
.as_ref(ctx)
|
||||
.free_user_no_ai_experiment()
|
||||
}
|
||||
|
||||
/// The current `use_vertical_tabs` value on the onboarding UI customization.
|
||||
/// This reflects the intention's default (agent = vertical, terminal = horizontal)
|
||||
/// and any change the user made on the customize slide, and is what the
|
||||
/// theme slide uses to pick its right-panel image.
|
||||
pub fn use_vertical_tabs(&self, ctx: &AppContext) -> bool {
|
||||
self.onboarding_state
|
||||
.as_ref(ctx)
|
||||
.ui_customization()
|
||||
.use_vertical_tabs
|
||||
}
|
||||
|
||||
pub fn set_agent_price_cents(&mut self, cents: Option<i32>, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |state, ctx| {
|
||||
state.set_agent_price_cents(cents, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn set_free_user_no_ai_experiment(&mut self, value: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |state, ctx| {
|
||||
state.set_free_user_no_ai_experiment(value, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// When the user upgrades during the FreeUserNoAi experiment, advance directly
|
||||
/// to the Agent setup step (skipping the intention slide — they've already chosen).
|
||||
pub fn advance_to_agent_step(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let step = self.onboarding_state.as_ref(ctx).step();
|
||||
if matches!(step, OnboardingStep::Intention) {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_intention_agent_driven_development(ctx);
|
||||
model.next(ctx); // Intention → Agent
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_onboarding(&self, ctx: &mut ViewContext<Self>) {
|
||||
// Focus the onboarding view so key bindings (Enter, arrow keys, etc.) are routed here
|
||||
// instead of to other views (e.g. the editor).
|
||||
ctx.focus_self();
|
||||
|
||||
// Preload customize-slide images so they're ready when the user reaches that slide.
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
Self::preload_onboarding_images(ctx);
|
||||
}
|
||||
|
||||
send_telemetry_from_ctx!(OnboardingEvent::OnboardingStarted, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SlideViewed {
|
||||
slide_name: "intro".to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
/// Eagerly loads all onboarding slide images into the asset cache
|
||||
/// so they display instantly when the user navigates between slides.
|
||||
fn preload_onboarding_images(ctx: &mut ViewContext<Self>) {
|
||||
let asset_cache = warpui::assets::asset_cache::AssetCache::as_ref(ctx);
|
||||
// Preload the shared background image used on all right panels.
|
||||
asset_cache.load_asset::<ImageType>(AssetSource::Bundled {
|
||||
path: crate::slides::layout::ONBOARDING_BG_PATH,
|
||||
});
|
||||
for path in IntentionSlide::VISUAL_IMAGE_PATHS {
|
||||
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
|
||||
}
|
||||
for path in CustomizeUISlide::VISUAL_IMAGE_PATHS {
|
||||
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
|
||||
}
|
||||
for path in ThirdPartySlide::VISUAL_IMAGE_PATHS {
|
||||
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
|
||||
}
|
||||
for path in ThemePickerSlide::VISUAL_IMAGE_PATHS {
|
||||
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
|
||||
}
|
||||
// Agent slide reuses customize_vertical_tabs / customize_horizontal_tabs
|
||||
// which are already in CustomizeUISlide::VISUAL_IMAGE_PATHS.
|
||||
}
|
||||
|
||||
fn handle_onboarding_completed(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let settings = self.onboarding_state.as_ref(ctx).settings();
|
||||
ctx.emit(AgentOnboardingEvent::OnboardingCompleted(settings));
|
||||
}
|
||||
|
||||
fn handle_theme_picker_slide_event(
|
||||
&mut self,
|
||||
event: &ThemePickerSlideEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
ThemePickerSlideEvent::ThemeSelected { theme_name } => {
|
||||
ctx.emit(AgentOnboardingEvent::ThemeSelected {
|
||||
theme_name: theme_name.clone(),
|
||||
});
|
||||
}
|
||||
ThemePickerSlideEvent::SyncWithOsToggled { enabled } => {
|
||||
ctx.emit(AgentOnboardingEvent::SyncWithOsToggled { enabled: *enabled });
|
||||
}
|
||||
ThemePickerSlideEvent::PrivacySettingsRequested => {
|
||||
ctx.emit(AgentOnboardingEvent::PrivacySettingsFromTerminalThemeSlideRequested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AgentOnboardingView {
|
||||
type Event = AgentOnboardingEvent;
|
||||
}
|
||||
|
||||
impl View for AgentOnboardingView {
|
||||
fn ui_name() -> &'static str {
|
||||
"AgentOnboardingView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
|
||||
if let Some(img) = theme.background_image() {
|
||||
// Render the image behind everything.
|
||||
stack.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Image::new(img.source(), CacheOption::Original)
|
||||
.cover()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Overlay the theme background so the image shows through at img.opacity.
|
||||
let overlay_opacity = (100u8).saturating_sub(img.opacity);
|
||||
stack.add_child(
|
||||
Rect::new()
|
||||
.with_background(theme.background().with_opacity(overlay_opacity))
|
||||
.finish(),
|
||||
);
|
||||
} else {
|
||||
stack.add_child(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background(theme.background())
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let selected_slide = self.onboarding_state.as_ref(app).step();
|
||||
let slide = match selected_slide {
|
||||
OnboardingStep::Intro => ChildView::new(&self.intro_slide).finish(),
|
||||
OnboardingStep::ThemePicker => ChildView::new(&self.theme_picker_slide).finish(),
|
||||
OnboardingStep::Intention => {
|
||||
if self
|
||||
.onboarding_state
|
||||
.as_ref(app)
|
||||
.free_user_no_ai_experiment()
|
||||
{
|
||||
ChildView::new(&self.free_user_no_ai_slide).finish()
|
||||
} else {
|
||||
ChildView::new(&self.intention_slide).finish()
|
||||
}
|
||||
}
|
||||
OnboardingStep::Customize => ChildView::new(&self.customize_slide).finish(),
|
||||
OnboardingStep::Agent => ChildView::new(&self.agent_slide).finish(),
|
||||
OnboardingStep::ThirdParty => ChildView::new(&self.third_party_slide).finish(),
|
||||
OnboardingStep::Project => ChildView::new(&self.project_slide).finish(),
|
||||
};
|
||||
|
||||
stack.add_child(slide);
|
||||
|
||||
if self.skippable {
|
||||
let esc = Keystroke::parse("escape").unwrap_or_default();
|
||||
|
||||
let close_button = self.close_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label("Skip".into()),
|
||||
theme: &button::themes::Naked,
|
||||
options: button::Options {
|
||||
size: button::Size::Small,
|
||||
keystroke: Some(esc),
|
||||
on_click: Some(Box::new(|ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(AgentOnboardingAction::Escape);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
stack.add_positioned_child(
|
||||
close_button,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-24., 24.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for AgentOnboardingView {
|
||||
type Action = AgentOnboardingAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
if matches!(action, AgentOnboardingAction::Escape) && self.skippable {
|
||||
ctx.emit(AgentOnboardingEvent::OnboardingSkipped);
|
||||
return;
|
||||
}
|
||||
|
||||
let selected_slide = self.onboarding_state.as_ref(ctx).step();
|
||||
|
||||
match selected_slide {
|
||||
OnboardingStep::Intro => self.intro_slide.update(ctx, |slide, ctx| {
|
||||
dispatch_onboarding_action_to_slide(slide, *action, ctx)
|
||||
}),
|
||||
OnboardingStep::ThemePicker => self.theme_picker_slide.update(ctx, |slide, ctx| {
|
||||
dispatch_onboarding_action_to_slide(slide, *action, ctx)
|
||||
}),
|
||||
OnboardingStep::Intention => {
|
||||
if self
|
||||
.onboarding_state
|
||||
.as_ref(ctx)
|
||||
.free_user_no_ai_experiment()
|
||||
{
|
||||
self.free_user_no_ai_slide.update(ctx, |slide, ctx| {
|
||||
dispatch_onboarding_action_to_slide(slide, *action, ctx)
|
||||
})
|
||||
} else {
|
||||
self.intention_slide.update(ctx, |slide, ctx| {
|
||||
dispatch_onboarding_action_to_slide(slide, *action, ctx)
|
||||
})
|
||||
}
|
||||
}
|
||||
OnboardingStep::Customize => self.customize_slide.update(ctx, |slide, ctx| {
|
||||
dispatch_onboarding_action_to_slide(slide, *action, ctx)
|
||||
}),
|
||||
OnboardingStep::Agent => self.agent_slide.update(ctx, |slide, ctx| {
|
||||
dispatch_onboarding_action_to_slide(slide, *action, ctx)
|
||||
}),
|
||||
OnboardingStep::ThirdParty => self.third_party_slide.update(ctx, |slide, ctx| {
|
||||
dispatch_onboarding_action_to_slide(slide, *action, ctx)
|
||||
}),
|
||||
OnboardingStep::Project => self.project_slide.update(ctx, |slide, ctx| {
|
||||
dispatch_onboarding_action_to_slide(slide, *action, ctx)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"up",
|
||||
AgentOnboardingAction::UpKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"down",
|
||||
AgentOnboardingAction::DownKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"left",
|
||||
AgentOnboardingAction::LeftKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"right",
|
||||
AgentOnboardingAction::RightKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"tab",
|
||||
AgentOnboardingAction::TabKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
AgentOnboardingAction::EnterKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"numpadenter",
|
||||
AgentOnboardingAction::EnterKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"cmdorctrl-enter",
|
||||
AgentOnboardingAction::CmdOrCtrlEnterKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"cmdorctrl-numpadenter",
|
||||
AgentOnboardingAction::CmdOrCtrlEnterKey,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
AgentOnboardingAction::Escape,
|
||||
id!(AgentOnboardingView::ui_name()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ai::LLMId;
|
||||
use anyhow::Result;
|
||||
use onboarding::slides::OnboardingModelInfo;
|
||||
use onboarding::{
|
||||
AgentOnboardingEvent, AgentOnboardingView, MockTelemetryContextProvider, SelectedSettings,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use rust_embed::RustEmbed;
|
||||
use std::borrow::Cow;
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::{AnsiColor, AnsiColors, Details, Fill, Image, TerminalColors};
|
||||
use warp_core::ui::{appearance::Appearance, theme::WarpTheme};
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::platform;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize, ParentElement,
|
||||
},
|
||||
fonts::{Cache, FamilyId, Weight},
|
||||
presenter::ChildView,
|
||||
ui_components::components::{UiComponent as _, UiComponentStyles},
|
||||
AddWindowOptions, AppContext, AssetProvider, Element, Entity, SingletonEntity as _,
|
||||
TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, RustEmbed)]
|
||||
#[folder = "../../app/assets"]
|
||||
pub struct Assets;
|
||||
|
||||
pub static ASSETS: Assets = Assets;
|
||||
|
||||
impl AssetProvider for Assets {
|
||||
fn get(&self, path: &str) -> Result<Cow<'_, [u8]>> {
|
||||
<Assets as RustEmbed>::get(path)
|
||||
.map(|f| f.data)
|
||||
.ok_or_else(|| anyhow::anyhow!(format!("no asset exists at path {path}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Initialize logging for the onboarding binary.
|
||||
warp_logging::init(warp_logging::LogConfig {
|
||||
is_cli: false,
|
||||
log_destination: None,
|
||||
})?;
|
||||
|
||||
let app_builder =
|
||||
platform::AppBuilder::new(platform::AppCallbacks::default(), Box::new(ASSETS), None);
|
||||
let _ = app_builder.run(move |ctx| {
|
||||
// Register Appearance singleton so views can access Appearance::handle(ctx).
|
||||
ctx.add_singleton_model(|ctx| build_appearance(phenomenon(), ctx));
|
||||
|
||||
// Register telemetry context provider for logging telemetry events.
|
||||
ctx.add_singleton_model(MockTelemetryContextProvider::new_context_provider);
|
||||
|
||||
ctx.add_window(AddWindowOptions::default(), |ctx| {
|
||||
OnboardingMainView::new(ctx)
|
||||
});
|
||||
|
||||
onboarding::init(ctx);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum OnboardingMainState {
|
||||
Onboarding(ViewHandle<AgentOnboardingView>),
|
||||
Finished(ViewHandle<FinishedOnboardingView>),
|
||||
}
|
||||
|
||||
struct OnboardingMainView {
|
||||
state: OnboardingMainState,
|
||||
}
|
||||
|
||||
impl OnboardingMainView {
|
||||
fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let themes = [phenomenon(), dark_theme(), light_theme(), adeberry()];
|
||||
let default_model_id = LLMId::from("auto");
|
||||
let models = vec![
|
||||
OnboardingModelInfo {
|
||||
id: LLMId::from("auto"),
|
||||
title: "Auto".to_string(),
|
||||
icon: Icon::Oz,
|
||||
requires_upgrade: false,
|
||||
is_default: true,
|
||||
},
|
||||
OnboardingModelInfo {
|
||||
id: LLMId::from("claude-sonnet"),
|
||||
title: "Claude Sonnet".to_string(),
|
||||
icon: Icon::ClaudeLogo,
|
||||
requires_upgrade: false,
|
||||
is_default: false,
|
||||
},
|
||||
OnboardingModelInfo {
|
||||
id: LLMId::from("gpt-4o"),
|
||||
title: "GPT-4o".to_string(),
|
||||
icon: Icon::OpenAILogo,
|
||||
requires_upgrade: true,
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
let onboarding_view = ctx.add_typed_action_view(move |ctx| {
|
||||
// agent_modality_enabled and no_ai_experiment are false for demo purposes
|
||||
AgentOnboardingView::new(
|
||||
themes.clone(),
|
||||
true,
|
||||
models.clone(),
|
||||
default_model_id.clone(),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
onboarding::OnboardingAuthState::LoggedOut,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
onboarding_view.update(ctx, |view, ctx| {
|
||||
view.start_onboarding(ctx);
|
||||
});
|
||||
ctx.subscribe_to_view(&onboarding_view, |me, _view, event, ctx| {
|
||||
me.handle_onboarding_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
state: OnboardingMainState::Onboarding(onboarding_view),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_onboarding_event(
|
||||
&mut self,
|
||||
event: &AgentOnboardingEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
AgentOnboardingEvent::ThemeSelected { theme_name } => {
|
||||
let theme = match theme_name.as_str() {
|
||||
"Phenomenon" => phenomenon(),
|
||||
"Dark" => dark_theme(),
|
||||
"Light" => light_theme(),
|
||||
"Adeberry" => adeberry(),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
||||
appearance.set_theme(theme, ctx);
|
||||
});
|
||||
}
|
||||
AgentOnboardingEvent::OnboardingCompleted(selected_settings) => {
|
||||
let finished_view = ctx.add_typed_action_view(|_| {
|
||||
FinishedOnboardingView::new(Some(selected_settings.clone()))
|
||||
});
|
||||
self.state = OnboardingMainState::Finished(finished_view);
|
||||
ctx.notify();
|
||||
}
|
||||
AgentOnboardingEvent::OnboardingSkipped => {
|
||||
let finished_view =
|
||||
ctx.add_typed_action_view(|_| FinishedOnboardingView::new(None));
|
||||
self.state = OnboardingMainState::Finished(finished_view);
|
||||
ctx.notify();
|
||||
}
|
||||
AgentOnboardingEvent::SyncWithOsToggled { .. }
|
||||
| AgentOnboardingEvent::UpgradeRequested
|
||||
| AgentOnboardingEvent::UpgradeCopyUrlRequested
|
||||
| AgentOnboardingEvent::UpgradePasteTokenFromClipboardRequested
|
||||
| AgentOnboardingEvent::LoginFromWelcomeRequested
|
||||
| AgentOnboardingEvent::PrivacySettingsFromTerminalThemeSlideRequested
|
||||
| AgentOnboardingEvent::AppBecameActive => {
|
||||
// No-op in the standalone demo binary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FinishedOnboardingView {
|
||||
selected_settings: Option<SelectedSettings>,
|
||||
}
|
||||
|
||||
impl FinishedOnboardingView {
|
||||
fn new(selected_settings: Option<SelectedSettings>) -> Self {
|
||||
Self { selected_settings }
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FinishedOnboardingView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for FinishedOnboardingView {
|
||||
fn ui_name() -> &'static str {
|
||||
"FinishedOnboardingView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let header_text = if self.selected_settings.is_some() {
|
||||
"Finished Onboarding"
|
||||
} else {
|
||||
"Skipped Onboarding"
|
||||
};
|
||||
|
||||
let header = appearance
|
||||
.ui_builder()
|
||||
.paragraph(header_text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(28.),
|
||||
font_weight: Some(Weight::Medium),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let details_text = match &self.selected_settings {
|
||||
Some(selected_settings) => format!("SelectedSettings: {selected_settings:?}"),
|
||||
None => "SelectedSettings: (none)".to_string(),
|
||||
};
|
||||
|
||||
let details = appearance
|
||||
.ui_builder()
|
||||
.paragraph(details_text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_weight: Some(Weight::Normal),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let theme = appearance.theme();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(header)
|
||||
.with_child(Container::new(details).with_margin_top(12.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_background(theme.background())
|
||||
.with_uniform_padding(64.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for FinishedOnboardingView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
impl Entity for OnboardingMainView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for OnboardingMainView {
|
||||
fn ui_name() -> &'static str {
|
||||
"OnboardingMainView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
match &self.state {
|
||||
OnboardingMainState::Onboarding(view) => ChildView::new(view).finish(),
|
||||
OnboardingMainState::Finished(view) => ChildView::new(view).finish(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if let OnboardingMainState::Onboarding(view) = &self.state {
|
||||
if focus_ctx.is_self_focused() {
|
||||
ctx.focus(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for OnboardingMainView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
// ---- Theme definitions copied from app::themes::default_themes (subset) ----
|
||||
|
||||
const DARK_MODE_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
||||
AnsiColor::from_u32(0x616161FF),
|
||||
AnsiColor::from_u32(0xFF8272FF),
|
||||
AnsiColor::from_u32(0xB4FA72FF),
|
||||
AnsiColor::from_u32(0xFEFDC2FF),
|
||||
AnsiColor::from_u32(0xA5D5FEFF),
|
||||
AnsiColor::from_u32(0xFF8FFDFF),
|
||||
AnsiColor::from_u32(0xD0D1FEFF),
|
||||
AnsiColor::from_u32(0xF1F1F1FF),
|
||||
);
|
||||
|
||||
const DARK_MODE_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
||||
AnsiColor::from_u32(0x8E8E8EFF),
|
||||
AnsiColor::from_u32(0xFFC4BDFF),
|
||||
AnsiColor::from_u32(0xD6FCB9FF),
|
||||
AnsiColor::from_u32(0xFEFDD5FF),
|
||||
AnsiColor::from_u32(0xC1E3FEFF),
|
||||
AnsiColor::from_u32(0xFFB1FEFF),
|
||||
AnsiColor::from_u32(0xE5E6FEFF),
|
||||
AnsiColor::from_u32(0xFEFFFFFF),
|
||||
);
|
||||
|
||||
const LIGHT_MODE_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
||||
AnsiColor::from_u32(0x212121FF),
|
||||
AnsiColor::from_u32(0xC30771FF),
|
||||
AnsiColor::from_u32(0x10A778FF),
|
||||
AnsiColor::from_u32(0xA89C14FF),
|
||||
AnsiColor::from_u32(0x008EC4FF),
|
||||
AnsiColor::from_u32(0x523C79FF),
|
||||
AnsiColor::from_u32(0x20A5BAFF),
|
||||
AnsiColor::from_u32(0xE0E0E0FF),
|
||||
);
|
||||
|
||||
const LIGHT_MODE_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
||||
AnsiColor::from_u32(0x212121FF),
|
||||
AnsiColor::from_u32(0xFB007AFF),
|
||||
AnsiColor::from_u32(0x5FD7AFFF),
|
||||
AnsiColor::from_u32(0xF3E430FF),
|
||||
AnsiColor::from_u32(0x20BBFCFF),
|
||||
AnsiColor::from_u32(0x6855DEFF),
|
||||
AnsiColor::from_u32(0x4FB8CCFF),
|
||||
AnsiColor::from_u32(0xF1F1F1FF),
|
||||
);
|
||||
|
||||
const PHENOMENON_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
||||
AnsiColor::from_u32(0x121212FF),
|
||||
AnsiColor::from_u32(0xD22D1EFF),
|
||||
AnsiColor::from_u32(0x1CA05AFF),
|
||||
AnsiColor::from_u32(0xE5A01AFF),
|
||||
AnsiColor::from_u32(0x3780E9FF),
|
||||
AnsiColor::from_u32(0xBF409DFF),
|
||||
AnsiColor::from_u32(0x799C92FF),
|
||||
AnsiColor::from_u32(0xFAF9F6FF),
|
||||
);
|
||||
|
||||
const PHENOMENON_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
||||
AnsiColor::from_u32(0x292929FF),
|
||||
AnsiColor::from_u32(0xAE756FFF),
|
||||
AnsiColor::from_u32(0x789B88FF),
|
||||
AnsiColor::from_u32(0xBD9F65FF),
|
||||
AnsiColor::from_u32(0x6F839FFF),
|
||||
AnsiColor::from_u32(0xA57899FF),
|
||||
AnsiColor::from_u32(0xBFC5C3FF),
|
||||
AnsiColor::from_u32(0xFFFFFFFF),
|
||||
);
|
||||
|
||||
const ADEBERRY_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
||||
AnsiColor::from_u32(0x121212FF),
|
||||
AnsiColor::from_u32(0xC76156FF),
|
||||
AnsiColor::from_u32(0x57C78AFF),
|
||||
AnsiColor::from_u32(0xC8A35AFF),
|
||||
AnsiColor::from_u32(0x5785C7FF),
|
||||
AnsiColor::from_u32(0xC756A9FF),
|
||||
AnsiColor::from_u32(0x57C7C3FF),
|
||||
AnsiColor::from_u32(0xEEEDEBFF),
|
||||
);
|
||||
|
||||
const ADEBERRY_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
||||
AnsiColor::from_u32(0x292929FF),
|
||||
AnsiColor::from_u32(0xE3493BFF),
|
||||
AnsiColor::from_u32(0x1CA05AFF),
|
||||
AnsiColor::from_u32(0xE3AA3BFF),
|
||||
AnsiColor::from_u32(0x3BE38AFF),
|
||||
AnsiColor::from_u32(0xC8A35AFF),
|
||||
AnsiColor::from_u32(0x3BE3DDFF),
|
||||
AnsiColor::from_u32(0xFFFFFFFF),
|
||||
);
|
||||
|
||||
fn dark_mode_colors() -> TerminalColors {
|
||||
TerminalColors::new(DARK_MODE_NORMAL_COLORS, DARK_MODE_BRIGHT_COLORS)
|
||||
}
|
||||
|
||||
fn light_mode_colors() -> TerminalColors {
|
||||
TerminalColors::new(LIGHT_MODE_NORMAL_COLORS, LIGHT_MODE_BRIGHT_COLORS)
|
||||
}
|
||||
|
||||
fn phenomenon_colors() -> TerminalColors {
|
||||
TerminalColors::new(PHENOMENON_NORMAL_COLORS, PHENOMENON_BRIGHT_COLORS)
|
||||
}
|
||||
|
||||
fn adeberry_colors() -> TerminalColors {
|
||||
TerminalColors::new(ADEBERRY_NORMAL_COLORS, ADEBERRY_BRIGHT_COLORS)
|
||||
}
|
||||
|
||||
fn dark_theme() -> WarpTheme {
|
||||
WarpTheme::new(
|
||||
Fill::Solid(ColorU::from_u32(0x000000FF)),
|
||||
ColorU::from_u32(0xffffffff),
|
||||
Fill::Solid(ColorU::from_u32(0x19AAD8FF)),
|
||||
None,
|
||||
Some(Details::Darker),
|
||||
dark_mode_colors(),
|
||||
None,
|
||||
Some("Dark".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn light_theme() -> WarpTheme {
|
||||
WarpTheme::new(
|
||||
Fill::Solid(ColorU::white()),
|
||||
ColorU::new(17, 17, 17, 0xFF),
|
||||
Fill::Solid(ColorU::from_u32(0x00c2ffff)),
|
||||
None,
|
||||
Some(Details::Lighter),
|
||||
light_mode_colors(),
|
||||
None,
|
||||
Some("Light".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn phenomenon() -> WarpTheme {
|
||||
WarpTheme::new(
|
||||
Fill::Solid(ColorU::from_u32(0x121212FF)),
|
||||
ColorU::from_u32(0xFAF9F6FF),
|
||||
Fill::Solid(ColorU::from_u32(0x2E5D9EFF)),
|
||||
None,
|
||||
Some(Details::Darker),
|
||||
phenomenon_colors(),
|
||||
Some(Image {
|
||||
source: AssetSource::Bundled {
|
||||
// Match app's asset layout: this image lives under app/assets/async.
|
||||
path: "async/jpg/phenomenon_bg.jpg",
|
||||
},
|
||||
opacity: 100,
|
||||
}),
|
||||
Some("Phenomenon".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn adeberry() -> WarpTheme {
|
||||
WarpTheme::new(
|
||||
Fill::Solid(ColorU::from_u32(0x1D2022FF)),
|
||||
ColorU::from_u32(0xE4EEF5FF),
|
||||
Fill::Solid(ColorU::from_u32(0x6C96B4FF)),
|
||||
None,
|
||||
Some(Details::Darker),
|
||||
adeberry_colors(),
|
||||
None,
|
||||
Some("Adeberry".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_appearance(theme: WarpTheme, ctx: &mut AppContext) -> Appearance {
|
||||
let ui_font_family =
|
||||
load_default_ui_font_family(ctx).expect("unable to load default ui font family");
|
||||
|
||||
Appearance::new(
|
||||
theme,
|
||||
ui_font_family,
|
||||
13.0,
|
||||
Weight::Normal,
|
||||
ui_font_family,
|
||||
1.2,
|
||||
ui_font_family,
|
||||
ui_font_family,
|
||||
)
|
||||
}
|
||||
|
||||
fn load_default_ui_font_family(ctx: &mut AppContext) -> anyhow::Result<FamilyId> {
|
||||
Cache::handle(ctx).update(ctx, |font_cache, _| {
|
||||
// On Windows, default to use Segoe UI as the UI font.
|
||||
#[cfg(windows)]
|
||||
if let Ok(font_family_id) = font_cache.load_system_font("Segoe UI") {
|
||||
return Ok(font_family_id);
|
||||
}
|
||||
|
||||
font_cache.load_family_from_bytes(
|
||||
"Roboto",
|
||||
vec![
|
||||
ASSETS
|
||||
.get("bundled/fonts/roboto/Roboto-Italic.ttf")?
|
||||
.to_vec(),
|
||||
ASSETS.get("bundled/fonts/roboto/Roboto-Bold.ttf")?.to_vec(),
|
||||
ASSETS
|
||||
.get("bundled/fonts/roboto/Roboto-Regular.ttf")?
|
||||
.to_vec(),
|
||||
ASSETS
|
||||
.get("bundled/fonts/roboto/Roboto-Medium.ttf")?
|
||||
.to_vec(),
|
||||
ASSETS
|
||||
.get("bundled/fonts/roboto/RobotoFlex-Semibold.ttf")?
|
||||
.to_vec(),
|
||||
ASSETS
|
||||
.get("bundled/fonts/roboto/Roboto-BoldItalic.ttf")?
|
||||
.to_vec(),
|
||||
],
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod model;
|
||||
mod view;
|
||||
|
||||
pub use model::{FinalState, OnboardingQuery};
|
||||
pub use view::{OnboardingCalloutView, OnboardingCalloutViewEvent, OnboardingKeybindings};
|
||||
|
||||
pub fn init(app: &mut warpui::AppContext) {
|
||||
view::init(app);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
use crate::telemetry::OnboardingEvent;
|
||||
use crate::OnboardingIntention;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FinalState {
|
||||
/// User submitted the agent query (legacy flow)
|
||||
Submit,
|
||||
/// User skipped the callout (legacy flow or skip initialization)
|
||||
Skip,
|
||||
/// User finished the callout without submitting
|
||||
Finish,
|
||||
/// User chose to initialize the project (AgentModality with project)
|
||||
Initialize,
|
||||
/// User chose to go back to terminal (AgentModality without project)
|
||||
BackToTerminal,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FinalState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FinalState::Submit => write!(f, "submitted"),
|
||||
FinalState::Skip => write!(f, "skipped"),
|
||||
FinalState::Finish => write!(f, "finished"),
|
||||
FinalState::Initialize => write!(f, "initialize"),
|
||||
FinalState::BackToTerminal => write!(f, "back_to_terminal"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt information for the onboarding callout
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum OnboardingQuery {
|
||||
/// A terminal command that should be executed in shell mode
|
||||
TerminalCommand(String),
|
||||
/// An agent prompt that should be executed in agent mode
|
||||
AgentPrompt(String),
|
||||
/// No prompt (empty state)
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum OnboardingCalloutModelEvent {
|
||||
StateUpdated,
|
||||
Completed(FinalState),
|
||||
EnterAgentModality,
|
||||
/// Emitted when the user toggles the natural language detection checkbox.
|
||||
NaturalLanguageDetectionToggled(bool),
|
||||
}
|
||||
|
||||
/// State for the UniversalInput onboarding flow (non-AgentModality).
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum UniversalInputCalloutState {
|
||||
#[default]
|
||||
Off,
|
||||
MeetInput,
|
||||
TalkToAgent,
|
||||
Complete(FinalState),
|
||||
}
|
||||
|
||||
/// State for the AgentModality onboarding flow.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum AgentModalityCalloutState {
|
||||
#[default]
|
||||
Off,
|
||||
/// Step 1: "Meet your terminal input" / "Meet your updated terminal input"
|
||||
MeetTerminalInput,
|
||||
/// Step 2: "Natural language support" with checkbox
|
||||
NaturalLanguageSupport,
|
||||
/// Step 3: "Introducing Warp's new agent experience" (Agent intention only)
|
||||
IntroducingAgentExperience,
|
||||
/// Step 4: "Updated agent input" (Agent intention only)
|
||||
UpdatedAgentInput,
|
||||
/// Terminal state
|
||||
Complete(FinalState),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum OnboardingCalloutState {
|
||||
UniversalInput(UniversalInputCalloutState),
|
||||
AgentModality(AgentModalityCalloutState),
|
||||
}
|
||||
|
||||
pub(super) struct OnboardingCalloutModel {
|
||||
state: OnboardingCalloutState,
|
||||
intention: OnboardingIntention,
|
||||
has_project: bool,
|
||||
/// The initial value of natural language detection when onboarding started.
|
||||
/// Used to determine which callout variant to show.
|
||||
initial_natural_language_detection_enabled: bool,
|
||||
/// The current value of natural language detection (may change via checkbox toggle).
|
||||
natural_language_detection_enabled: bool,
|
||||
}
|
||||
|
||||
impl OnboardingCalloutModel {
|
||||
/// Create a new model for UniversalInput onboarding flow.
|
||||
pub fn new_universal_input(
|
||||
has_project: bool,
|
||||
initial_natural_language_detection_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::default()),
|
||||
intention: OnboardingIntention::AgentDrivenDevelopment,
|
||||
has_project,
|
||||
initial_natural_language_detection_enabled,
|
||||
natural_language_detection_enabled: initial_natural_language_detection_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new model for AgentModality onboarding flow.
|
||||
pub fn new_agent_modality(
|
||||
has_project: bool,
|
||||
intention: OnboardingIntention,
|
||||
initial_natural_language_detection_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: OnboardingCalloutState::AgentModality(AgentModalityCalloutState::default()),
|
||||
intention,
|
||||
has_project,
|
||||
initial_natural_language_detection_enabled,
|
||||
natural_language_detection_enabled: initial_natural_language_detection_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_project(&self) -> bool {
|
||||
self.has_project
|
||||
}
|
||||
|
||||
pub fn intention(&self) -> OnboardingIntention {
|
||||
self.intention
|
||||
}
|
||||
|
||||
pub fn initial_natural_language_detection_enabled(&self) -> bool {
|
||||
self.initial_natural_language_detection_enabled
|
||||
}
|
||||
|
||||
pub fn natural_language_detection_enabled(&self) -> bool {
|
||||
self.natural_language_detection_enabled
|
||||
}
|
||||
|
||||
pub fn toggle_natural_language_detection(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.natural_language_detection_enabled = !self.natural_language_detection_enabled;
|
||||
ctx.emit(
|
||||
OnboardingCalloutModelEvent::NaturalLanguageDetectionToggled(
|
||||
self.natural_language_detection_enabled,
|
||||
),
|
||||
);
|
||||
ctx.emit(OnboardingCalloutModelEvent::StateUpdated);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn next(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
send_telemetry_from_ctx!(OnboardingEvent::CalloutNext, ctx);
|
||||
match &self.state {
|
||||
OnboardingCalloutState::UniversalInput(universal_input_state) => {
|
||||
self.next_universal_input(*universal_input_state, ctx);
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(modality_state) => {
|
||||
self.next_agent_modality(*modality_state, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_universal_input(
|
||||
&mut self,
|
||||
state: UniversalInputCalloutState,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let next_state = match state {
|
||||
UniversalInputCalloutState::Off => Some(UniversalInputCalloutState::MeetInput),
|
||||
UniversalInputCalloutState::MeetInput => Some(UniversalInputCalloutState::TalkToAgent),
|
||||
UniversalInputCalloutState::TalkToAgent => {
|
||||
Some(UniversalInputCalloutState::Complete(FinalState::Submit))
|
||||
}
|
||||
UniversalInputCalloutState::Complete(_) => None,
|
||||
};
|
||||
if let Some(next_state) = next_state {
|
||||
self.set_state(OnboardingCalloutState::UniversalInput(next_state), ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn next_agent_modality(
|
||||
&mut self,
|
||||
state: AgentModalityCalloutState,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let (next_state, emit_enter_agent_modality) = match state {
|
||||
AgentModalityCalloutState::Off => {
|
||||
(Some(AgentModalityCalloutState::MeetTerminalInput), false)
|
||||
}
|
||||
AgentModalityCalloutState::MeetTerminalInput => (
|
||||
Some(AgentModalityCalloutState::NaturalLanguageSupport),
|
||||
false,
|
||||
),
|
||||
AgentModalityCalloutState::NaturalLanguageSupport => {
|
||||
// For Terminal intention, finish here
|
||||
// For Agent intention, continue to IntroducingAgentExperience
|
||||
match self.intention {
|
||||
OnboardingIntention::Terminal => (
|
||||
Some(AgentModalityCalloutState::Complete(FinalState::Finish)),
|
||||
false,
|
||||
),
|
||||
OnboardingIntention::AgentDrivenDevelopment => {
|
||||
// Signal to enter agent modality when showing the agent experience slide
|
||||
(
|
||||
Some(AgentModalityCalloutState::IntroducingAgentExperience),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AgentModalityCalloutState::IntroducingAgentExperience => {
|
||||
(Some(AgentModalityCalloutState::UpdatedAgentInput), false)
|
||||
}
|
||||
AgentModalityCalloutState::UpdatedAgentInput => {
|
||||
// For Agent with project: Initialize
|
||||
// For Agent without project: Finish
|
||||
let final_state = if self.has_project {
|
||||
FinalState::Initialize
|
||||
} else {
|
||||
FinalState::Finish
|
||||
};
|
||||
(
|
||||
Some(AgentModalityCalloutState::Complete(final_state)),
|
||||
false,
|
||||
)
|
||||
}
|
||||
AgentModalityCalloutState::Complete(_) => (None, false),
|
||||
};
|
||||
if let Some(next_state) = next_state {
|
||||
self.set_state(OnboardingCalloutState::AgentModality(next_state), ctx);
|
||||
}
|
||||
if emit_enter_agent_modality {
|
||||
ctx.emit(OnboardingCalloutModelEvent::EnterAgentModality);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
match &self.state {
|
||||
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::TalkToAgent) => {
|
||||
self.set_state(
|
||||
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::Complete(
|
||||
FinalState::Skip,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
|
||||
// Skip initialization
|
||||
self.set_state(
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
|
||||
FinalState::Skip,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
_ => log::error!(
|
||||
"Skip action called in an unskippable state: {:?}",
|
||||
self.state
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
match &self.state {
|
||||
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::TalkToAgent) => {
|
||||
self.set_state(
|
||||
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::Complete(
|
||||
FinalState::Finish,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(
|
||||
AgentModalityCalloutState::NaturalLanguageSupport,
|
||||
) => {
|
||||
// Terminal intention finishes here
|
||||
self.set_state(
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
|
||||
FinalState::Finish,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
|
||||
// Agent without project finishes here
|
||||
self.set_state(
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
|
||||
FinalState::Finish,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
_ => log::error!("Finish action called in an invalid state: {:?}", self.state),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle "Back to terminal" action (ESC in UpdatedAgentInput without project)
|
||||
pub fn back_to_terminal(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
match &self.state {
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
|
||||
self.set_state(
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
|
||||
FinalState::BackToTerminal,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
_ => log::error!(
|
||||
"BackToTerminal action called in an invalid state: {:?}",
|
||||
self.state
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_onboarding_active(&self) -> bool {
|
||||
match &self.state {
|
||||
OnboardingCalloutState::UniversalInput(state) => !matches!(
|
||||
state,
|
||||
UniversalInputCalloutState::Off | UniversalInputCalloutState::Complete(_)
|
||||
),
|
||||
OnboardingCalloutState::AgentModality(state) => !matches!(
|
||||
state,
|
||||
AgentModalityCalloutState::Off | AgentModalityCalloutState::Complete(_)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> OnboardingCalloutState {
|
||||
self.state
|
||||
}
|
||||
|
||||
fn send_callout_displayed_telemetry(
|
||||
new_state: OnboardingCalloutState,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let callout_name = match new_state {
|
||||
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::MeetInput) => {
|
||||
Some("meet_input")
|
||||
}
|
||||
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::TalkToAgent) => {
|
||||
Some("talk_to_agent")
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::MeetTerminalInput) => {
|
||||
Some("meet_terminal_input")
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(
|
||||
AgentModalityCalloutState::NaturalLanguageSupport,
|
||||
) => Some("natural_language_support"),
|
||||
OnboardingCalloutState::AgentModality(
|
||||
AgentModalityCalloutState::IntroducingAgentExperience,
|
||||
) => Some("introducing_agent_experience"),
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
|
||||
Some("updated_agent_input")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(callout) = callout_name {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::CalloutDisplayed {
|
||||
callout: callout.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_state(&mut self, new_state: OnboardingCalloutState, ctx: &mut ModelContext<Self>) {
|
||||
if self.state != new_state {
|
||||
self.state = new_state;
|
||||
Self::send_callout_displayed_telemetry(new_state, ctx);
|
||||
ctx.emit(OnboardingCalloutModelEvent::StateUpdated);
|
||||
|
||||
// Check for completion
|
||||
let final_state = match new_state {
|
||||
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::Complete(
|
||||
fs,
|
||||
)) => Some(fs),
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(fs)) => {
|
||||
Some(fs)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(final_state) = final_state {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::CalloutCompleted {
|
||||
completion_type: final_state.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
ctx.emit(OnboardingCalloutModelEvent::Completed(final_state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a prompt string to populate a command based on current state
|
||||
pub fn prompt_string(&self) -> String {
|
||||
match self.prompt() {
|
||||
OnboardingQuery::TerminalCommand(text) | OnboardingQuery::AgentPrompt(text) => text,
|
||||
OnboardingQuery::None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the prompt information including type for the current state
|
||||
pub fn prompt(&self) -> OnboardingQuery {
|
||||
match &self.state {
|
||||
OnboardingCalloutState::UniversalInput(state) => {
|
||||
self.prompt_for_universal_input(*state)
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(state) => self.prompt_for_agent_modality(*state),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_for_universal_input(&self, state: UniversalInputCalloutState) -> OnboardingQuery {
|
||||
match state {
|
||||
UniversalInputCalloutState::Off
|
||||
| UniversalInputCalloutState::Complete(FinalState::Skip)
|
||||
| UniversalInputCalloutState::Complete(FinalState::Finish) => OnboardingQuery::None,
|
||||
UniversalInputCalloutState::MeetInput => {
|
||||
OnboardingQuery::TerminalCommand("git status".to_string())
|
||||
}
|
||||
UniversalInputCalloutState::TalkToAgent
|
||||
| UniversalInputCalloutState::Complete(FinalState::Submit) => {
|
||||
OnboardingQuery::AgentPrompt(
|
||||
"What tests exist in this repo, how are they structured, and what do they cover?"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
UniversalInputCalloutState::Complete(_) => OnboardingQuery::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_for_agent_modality(&self, state: AgentModalityCalloutState) -> OnboardingQuery {
|
||||
match state {
|
||||
AgentModalityCalloutState::Off => OnboardingQuery::None,
|
||||
AgentModalityCalloutState::MeetTerminalInput => {
|
||||
OnboardingQuery::TerminalCommand("Run a command...".to_string())
|
||||
}
|
||||
AgentModalityCalloutState::NaturalLanguageSupport => {
|
||||
OnboardingQuery::AgentPrompt("help me terraform my Gcloud setup".to_string())
|
||||
}
|
||||
AgentModalityCalloutState::IntroducingAgentExperience => {
|
||||
OnboardingQuery::AgentPrompt("Tell the agent what to build...".to_string())
|
||||
}
|
||||
AgentModalityCalloutState::UpdatedAgentInput => {
|
||||
if self.has_project {
|
||||
OnboardingQuery::AgentPrompt("/init".to_string())
|
||||
} else {
|
||||
OnboardingQuery::AgentPrompt("Tell the agent what to build...".to_string())
|
||||
}
|
||||
}
|
||||
// All completion states should return None so the input gets cleared
|
||||
AgentModalityCalloutState::Complete(_) => OnboardingQuery::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_onboarding(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
log::info!(
|
||||
"start_onboarding called with current state: {:?}",
|
||||
self.state
|
||||
);
|
||||
match &self.state {
|
||||
OnboardingCalloutState::UniversalInput(_) => {
|
||||
log::info!("Transitioning to UniversalInput::MeetInput");
|
||||
self.set_state(
|
||||
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::MeetInput),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(_) => {
|
||||
log::info!("Transitioning to AgentModality::MeetTerminalInput");
|
||||
self.set_state(
|
||||
OnboardingCalloutState::AgentModality(
|
||||
AgentModalityCalloutState::MeetTerminalInput,
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for OnboardingCalloutModel {
|
||||
type Event = OnboardingCalloutModelEvent;
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
use ui_components::Component;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::Empty,
|
||||
keymap::{macros::*, FixedBinding, Keystroke},
|
||||
AppContext, Element, Entity, EventContext, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
/// Display strings for keybindings shown in the onboarding callout.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OnboardingKeybindings {
|
||||
/// Display string for toggling between agent/terminal mode (e.g., "⌘I")
|
||||
pub toggle_input_mode: String,
|
||||
/// Display string for submitting to local agent (e.g., "⌘⏎")
|
||||
pub submit_to_local_agent: String,
|
||||
/// Display string for submitting to cloud agent (e.g., "⌘⌥⏎")
|
||||
pub submit_to_cloud_agent: String,
|
||||
}
|
||||
|
||||
use crate::{
|
||||
callout::model::{
|
||||
AgentModalityCalloutState, FinalState, OnboardingCalloutModel, OnboardingCalloutModelEvent,
|
||||
OnboardingCalloutState, OnboardingQuery, UniversalInputCalloutState,
|
||||
},
|
||||
components::onboarding_callout::{self, Button, StepStatus},
|
||||
OnboardingIntention,
|
||||
};
|
||||
|
||||
/// Options for rendering a callout.
|
||||
struct CalloutOptions {
|
||||
title: &'static str,
|
||||
/// Pre-built text with keybindings already embedded
|
||||
text: String,
|
||||
step: StepStatus,
|
||||
right_button: ButtonOptions,
|
||||
/// Optional left button (e.g., "Skip", "Back to terminal")
|
||||
left_button: Option<ButtonOptions>,
|
||||
/// Optional checkbox for natural language detection
|
||||
checkbox: Option<CheckboxOptions>,
|
||||
}
|
||||
|
||||
struct ButtonOptions {
|
||||
text: &'static str,
|
||||
action: OnboardingCalloutViewAction,
|
||||
keystroke: Option<Keystroke>,
|
||||
}
|
||||
|
||||
struct CheckboxOptions {
|
||||
label: &'static str,
|
||||
checked: bool,
|
||||
}
|
||||
|
||||
fn get_universal_input_callout_options(
|
||||
state: UniversalInputCalloutState,
|
||||
has_project: bool,
|
||||
keybindings: &OnboardingKeybindings,
|
||||
) -> Option<CalloutOptions> {
|
||||
match state {
|
||||
UniversalInputCalloutState::MeetInput => Some(CalloutOptions {
|
||||
title: "Meet the Warp input",
|
||||
text: format!(
|
||||
"Your terminal input accepts both terminal commands and agent prompts and automatically detects which you're using. Use {} to lock the input to Agent mode (natural language) or Terminal mode (commands).",
|
||||
keybindings.toggle_input_mode
|
||||
),
|
||||
step: StepStatus::new(0, 2),
|
||||
left_button: None,
|
||||
right_button: ButtonOptions {
|
||||
text: "Next",
|
||||
action: OnboardingCalloutViewAction::NextClicked,
|
||||
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
|
||||
},
|
||||
checkbox: None,
|
||||
}),
|
||||
UniversalInputCalloutState::TalkToAgent => Some(CalloutOptions {
|
||||
title: "Talk to the agent",
|
||||
text: "You can type in natural language to engage the agent. Submit the query below to start: What tests exist in this repo, how are they structured, and what do they cover?".to_string(),
|
||||
step: StepStatus::new(1, 2),
|
||||
left_button: if has_project {
|
||||
Some(ButtonOptions {
|
||||
text: "Skip",
|
||||
action: OnboardingCalloutViewAction::SkipClicked,
|
||||
keystroke: Some(Keystroke::parse("delete").unwrap_or_default()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
right_button: ButtonOptions {
|
||||
text: if has_project { "Submit" } else { "Finish" },
|
||||
action: OnboardingCalloutViewAction::NextClicked,
|
||||
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
|
||||
},
|
||||
checkbox: None,
|
||||
}),
|
||||
UniversalInputCalloutState::Off | UniversalInputCalloutState::Complete(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_agent_modality_callout_options(
|
||||
state: AgentModalityCalloutState,
|
||||
intention: OnboardingIntention,
|
||||
has_project: bool,
|
||||
initial_natural_language_detection_enabled: bool,
|
||||
natural_language_detection_enabled: bool,
|
||||
keybindings: &OnboardingKeybindings,
|
||||
) -> Option<CalloutOptions> {
|
||||
let total_steps = match intention {
|
||||
OnboardingIntention::Terminal => 2,
|
||||
OnboardingIntention::AgentDrivenDevelopment => 4,
|
||||
};
|
||||
|
||||
match state {
|
||||
AgentModalityCalloutState::MeetTerminalInput => {
|
||||
let title: &'static str = if has_project || intention == OnboardingIntention::Terminal {
|
||||
"Meet your terminal input"
|
||||
} else {
|
||||
"Meet your updated terminal input"
|
||||
};
|
||||
Some(CalloutOptions {
|
||||
title,
|
||||
text: format!(
|
||||
"Run commands from the terminal, or use {} or {} to start or send to a local or cloud agent respectively.",
|
||||
keybindings.submit_to_local_agent,
|
||||
keybindings.submit_to_cloud_agent
|
||||
),
|
||||
step: StepStatus::new(0, total_steps),
|
||||
left_button: None,
|
||||
right_button: ButtonOptions {
|
||||
text: "Next",
|
||||
action: OnboardingCalloutViewAction::NextClicked,
|
||||
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
|
||||
},
|
||||
checkbox: None,
|
||||
})
|
||||
}
|
||||
AgentModalityCalloutState::NaturalLanguageSupport => {
|
||||
let is_final_step = intention == OnboardingIntention::Terminal;
|
||||
// Show different callout content based on initial NL detection state
|
||||
if initial_natural_language_detection_enabled {
|
||||
// NL detection was already enabled - show simpler "overrides" callout without checkbox
|
||||
Some(CalloutOptions {
|
||||
title: "Natural language overrides",
|
||||
text: format!(
|
||||
"You can always override any auto-detection using {}.",
|
||||
keybindings.toggle_input_mode
|
||||
),
|
||||
step: StepStatus::new(1, total_steps),
|
||||
left_button: None,
|
||||
right_button: ButtonOptions {
|
||||
text: if is_final_step { "Finish" } else { "Next" },
|
||||
action: OnboardingCalloutViewAction::NextClicked,
|
||||
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
|
||||
},
|
||||
checkbox: None,
|
||||
})
|
||||
} else {
|
||||
// NL detection was disabled - show full explanation with checkbox to enable
|
||||
Some(CalloutOptions {
|
||||
title: "Natural language support",
|
||||
text: format!(
|
||||
"Natural language input is off by default. If enabled, you can type requests in plain English and Warp will autodetect queries for the agent. You can always override them using {}.",
|
||||
keybindings.toggle_input_mode
|
||||
),
|
||||
step: StepStatus::new(1, total_steps),
|
||||
left_button: None,
|
||||
right_button: ButtonOptions {
|
||||
text: if is_final_step { "Finish" } else { "Next" },
|
||||
action: OnboardingCalloutViewAction::NextClicked,
|
||||
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
|
||||
},
|
||||
checkbox: Some(CheckboxOptions {
|
||||
label: "Enable Natural Language Detection",
|
||||
checked: natural_language_detection_enabled,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
AgentModalityCalloutState::IntroducingAgentExperience => Some(CalloutOptions {
|
||||
title: "Introducing Warp's new agent experience",
|
||||
text: "Agent conversations are now their own scoped view outside of your terminal. Simply hit ESC to return to the terminal at any point.".to_string(),
|
||||
step: StepStatus::new(2, total_steps),
|
||||
left_button: None,
|
||||
right_button: ButtonOptions {
|
||||
text: "Next",
|
||||
action: OnboardingCalloutViewAction::NextClicked,
|
||||
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
|
||||
},
|
||||
checkbox: None,
|
||||
}),
|
||||
AgentModalityCalloutState::UpdatedAgentInput => {
|
||||
if has_project {
|
||||
Some(CalloutOptions {
|
||||
title: "Updated agent input",
|
||||
text: "Your agent input will detect natural language as well as commands by default. Use ! to lock the input in bash mode to write commands.\n\nSubmit the query below to have the agent initialize this project, or ⊗ to clear the input and start your own!".to_string(),
|
||||
step: StepStatus::new(3, total_steps),
|
||||
left_button: Some(ButtonOptions {
|
||||
text: "Skip initialization",
|
||||
action: OnboardingCalloutViewAction::SkipClicked,
|
||||
keystroke: Some(Keystroke::parse("delete").unwrap_or_default()),
|
||||
}),
|
||||
right_button: ButtonOptions {
|
||||
text: "Initialize",
|
||||
action: OnboardingCalloutViewAction::NextClicked,
|
||||
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
|
||||
},
|
||||
checkbox: None,
|
||||
})
|
||||
} else {
|
||||
Some(CalloutOptions {
|
||||
title: "Updated agent input",
|
||||
text: "Your agent input will detect natural language as well as commands by default. Use ! to lock the input in bash mode to write commands.".to_string(),
|
||||
step: StepStatus::new(3, total_steps),
|
||||
left_button: Some(ButtonOptions {
|
||||
text: "Back to terminal",
|
||||
action: OnboardingCalloutViewAction::BackToTerminalClicked,
|
||||
keystroke: Some(Keystroke::parse("escape").unwrap_or_default()),
|
||||
}),
|
||||
right_button: ButtonOptions {
|
||||
text: "Finish",
|
||||
action: OnboardingCalloutViewAction::NextClicked,
|
||||
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
|
||||
},
|
||||
checkbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
AgentModalityCalloutState::Off | AgentModalityCalloutState::Complete(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum OnboardingCalloutViewAction {
|
||||
NextClicked,
|
||||
SkipClicked,
|
||||
BackToTerminalClicked,
|
||||
ToggleCheckbox,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
OnboardingCalloutViewAction::NextClicked,
|
||||
id!(OnboardingCalloutView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"numpadenter",
|
||||
OnboardingCalloutViewAction::NextClicked,
|
||||
id!(OnboardingCalloutView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"backspace",
|
||||
OnboardingCalloutViewAction::SkipClicked,
|
||||
id!(OnboardingCalloutView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
OnboardingCalloutViewAction::BackToTerminalClicked,
|
||||
id!(OnboardingCalloutView::ui_name()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum OnboardingCalloutViewEvent {
|
||||
StateUpdated,
|
||||
Completed {
|
||||
final_state: FinalState,
|
||||
},
|
||||
/// Signals that the terminal should enter agent modality (agent view).
|
||||
EnterAgentModality,
|
||||
/// Emitted when the user toggles the natural language detection checkbox.
|
||||
NaturalLanguageDetectionToggled(bool),
|
||||
}
|
||||
|
||||
/// A view that renders the onboarding callout UI component based on the current model state
|
||||
pub struct OnboardingCalloutView {
|
||||
/// Reference to the model that manages onboarding state
|
||||
model: ModelHandle<OnboardingCalloutModel>,
|
||||
/// The UI component that renders the actual callout
|
||||
callout_component: onboarding_callout::OnboardingCallout,
|
||||
/// Display strings for keybindings shown in the callout
|
||||
keybindings: OnboardingKeybindings,
|
||||
}
|
||||
|
||||
impl OnboardingCalloutView {
|
||||
/// Create a new view for the UniversalInput onboarding flow.
|
||||
pub fn new_universal_input(
|
||||
has_project: bool,
|
||||
initial_natural_language_detection_enabled: bool,
|
||||
keybindings: OnboardingKeybindings,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let model = ctx.add_model(|_ctx| {
|
||||
OnboardingCalloutModel::new_universal_input(
|
||||
has_project,
|
||||
initial_natural_language_detection_enabled,
|
||||
)
|
||||
});
|
||||
Self::with_model(model, keybindings, ctx)
|
||||
}
|
||||
|
||||
/// Create a new view for the AgentModality onboarding flow.
|
||||
pub fn new_agent_modality(
|
||||
has_project: bool,
|
||||
intention: OnboardingIntention,
|
||||
initial_natural_language_detection_enabled: bool,
|
||||
keybindings: OnboardingKeybindings,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let model = ctx.add_model(|_ctx| {
|
||||
OnboardingCalloutModel::new_agent_modality(
|
||||
has_project,
|
||||
intention,
|
||||
initial_natural_language_detection_enabled,
|
||||
)
|
||||
});
|
||||
Self::with_model(model, keybindings, ctx)
|
||||
}
|
||||
|
||||
fn with_model(
|
||||
model: ModelHandle<OnboardingCalloutModel>,
|
||||
keybindings: OnboardingKeybindings,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
// Re-emit model updates as view events so parents can subscribe to the view.
|
||||
ctx.subscribe_to_model(&model, |_me, _model, event, ctx| match event {
|
||||
OnboardingCalloutModelEvent::StateUpdated => {
|
||||
ctx.emit(OnboardingCalloutViewEvent::StateUpdated);
|
||||
ctx.notify();
|
||||
}
|
||||
OnboardingCalloutModelEvent::Completed(final_state) => {
|
||||
ctx.emit(OnboardingCalloutViewEvent::Completed {
|
||||
final_state: *final_state,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
OnboardingCalloutModelEvent::EnterAgentModality => {
|
||||
ctx.emit(OnboardingCalloutViewEvent::EnterAgentModality);
|
||||
ctx.notify();
|
||||
}
|
||||
OnboardingCalloutModelEvent::NaturalLanguageDetectionToggled(enabled) => {
|
||||
ctx.emit(OnboardingCalloutViewEvent::NaturalLanguageDetectionToggled(
|
||||
*enabled,
|
||||
));
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
model,
|
||||
callout_component: onboarding_callout::OnboardingCallout::default(),
|
||||
keybindings,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_project(&self, app: &AppContext) -> bool {
|
||||
self.model.as_ref(app).has_project()
|
||||
}
|
||||
|
||||
pub fn start_onboarding(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.start_onboarding(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn is_onboarding_active(&self, app: &AppContext) -> bool {
|
||||
self.model.as_ref(app).is_onboarding_active()
|
||||
}
|
||||
|
||||
pub fn prompt_string(&self, app: &AppContext) -> String {
|
||||
self.model.as_ref(app).prompt_string()
|
||||
}
|
||||
|
||||
pub fn prompt(&self, app: &AppContext) -> OnboardingQuery {
|
||||
self.model.as_ref(app).prompt()
|
||||
}
|
||||
|
||||
/// Returns true if the callout should be positioned above the zero state.
|
||||
/// For UpdatedAgentInput state, always position relative to the input box instead.
|
||||
pub fn should_position_above_zero_state(&self, app: &AppContext) -> bool {
|
||||
!matches!(
|
||||
self.model.as_ref(app).state(),
|
||||
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput)
|
||||
)
|
||||
}
|
||||
|
||||
fn get_callout_options(&self, app: &AppContext) -> Option<CalloutOptions> {
|
||||
let model = self.model.as_ref(app);
|
||||
match model.state() {
|
||||
OnboardingCalloutState::UniversalInput(state) => {
|
||||
get_universal_input_callout_options(state, model.has_project(), &self.keybindings)
|
||||
}
|
||||
OnboardingCalloutState::AgentModality(state) => get_agent_modality_callout_options(
|
||||
state,
|
||||
model.intention(),
|
||||
model.has_project(),
|
||||
model.initial_natural_language_detection_enabled(),
|
||||
model.natural_language_detection_enabled(),
|
||||
&self.keybindings,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for OnboardingCalloutView {
|
||||
type Event = OnboardingCalloutViewEvent;
|
||||
}
|
||||
|
||||
impl View for OnboardingCalloutView {
|
||||
fn ui_name() -> &'static str {
|
||||
"OnboardingCalloutView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let model = self.model.as_ref(app);
|
||||
|
||||
// Check if onboarding is active and render appropriate callout based on state
|
||||
if !model.is_onboarding_active() {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
let Some(options) = self.get_callout_options(app) else {
|
||||
log::warn!(
|
||||
"Onboarding callout view: onboarding is active but state has no callout options: {:?}",
|
||||
model.state()
|
||||
);
|
||||
return Empty::new().finish();
|
||||
};
|
||||
|
||||
let right_button = Button {
|
||||
text: options.right_button.text.into(),
|
||||
keystroke: options.right_button.keystroke,
|
||||
handler: Box::new(move |ctx: &mut EventContext, _app_ctx: &AppContext, _pos| {
|
||||
ctx.dispatch_typed_action(options.right_button.action.clone());
|
||||
}),
|
||||
};
|
||||
|
||||
let left_button = options.left_button.map(|left_opts| Button {
|
||||
text: left_opts.text.into(),
|
||||
keystroke: left_opts.keystroke,
|
||||
handler: Box::new(move |ctx: &mut EventContext, _app_ctx: &AppContext, _pos| {
|
||||
ctx.dispatch_typed_action(left_opts.action.clone());
|
||||
}),
|
||||
});
|
||||
|
||||
let checkbox = options
|
||||
.checkbox
|
||||
.map(|checkbox_opts| onboarding_callout::Checkbox {
|
||||
label: checkbox_opts.label.into(),
|
||||
checked: checkbox_opts.checked,
|
||||
handler: Box::new(|ctx: &mut EventContext, _app_ctx: &AppContext, _pos| {
|
||||
ctx.dispatch_typed_action(OnboardingCalloutViewAction::ToggleCheckbox);
|
||||
}),
|
||||
});
|
||||
|
||||
// Render the callout component with data from the model state
|
||||
self.callout_component.render(
|
||||
appearance,
|
||||
onboarding_callout::Params {
|
||||
title: options.title.to_string().into(),
|
||||
text: options.text.into(),
|
||||
step: options.step,
|
||||
right_button,
|
||||
options: onboarding_callout::Options {
|
||||
left_button,
|
||||
checkbox,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for OnboardingCalloutView {
|
||||
type Action = OnboardingCalloutViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
OnboardingCalloutViewAction::NextClicked => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
// Handle special cases for UniversalInput flow
|
||||
if let OnboardingCalloutState::UniversalInput(
|
||||
UniversalInputCalloutState::TalkToAgent,
|
||||
) = model.state()
|
||||
{
|
||||
if !model.has_project() {
|
||||
model.finish(ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
model.next(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
OnboardingCalloutViewAction::SkipClicked => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.skip(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
OnboardingCalloutViewAction::BackToTerminalClicked => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.back_to_terminal(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
OnboardingCalloutViewAction::ToggleCheckbox => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.toggle_natural_language_detection(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SingletonEntity for OnboardingCalloutView {}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod onboarding_callout;
|
||||
@@ -0,0 +1,400 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use ui_components::{
|
||||
button, button::Button as ButtonComponent, Component, MouseEventHandler, Options as _,
|
||||
};
|
||||
use warp_core::ui::{
|
||||
appearance::Appearance,
|
||||
color::{coloru_with_opacity, contrast::relative_luminance},
|
||||
theme::{phenomenon::PhenomenonStyle, Fill},
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Flex,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Rect,
|
||||
},
|
||||
fonts::Weight,
|
||||
keymap::Keystroke,
|
||||
prelude::*,
|
||||
ui_components::checkbox::Checkbox as WarpCheckbox,
|
||||
ui_components::components::{UiComponent as _, UiComponentStyles},
|
||||
};
|
||||
|
||||
const CALLOUT_WIDTH: f32 = 480.;
|
||||
const CALLOUT_BORDER_WIDTH: f32 = 1.;
|
||||
const CALLOUT_CORNER_RADIUS: f32 = 8.;
|
||||
const CALLOUT_PADDING: f32 = 16.;
|
||||
|
||||
struct PhenomenonPrimaryButtonTheme;
|
||||
struct PhenomenonSecondaryButtonTheme;
|
||||
|
||||
impl button::Theme for PhenomenonPrimaryButtonTheme {
|
||||
fn background(&self, button_state: button::State, _appearance: &Appearance) -> Option<Fill> {
|
||||
let hovered = matches!(
|
||||
button_state,
|
||||
button::State::Hovered | button::State::Pressed
|
||||
);
|
||||
Some(PhenomenonStyle::primary_button_background(hovered))
|
||||
}
|
||||
|
||||
fn text_color(&self, _background: Option<Fill>, _appearance: &Appearance) -> ColorU {
|
||||
PhenomenonStyle::primary_button_text()
|
||||
}
|
||||
|
||||
fn keyboard_shortcut_border(&self, text_color: ColorU, _: &Appearance) -> Option<ColorU> {
|
||||
Some(coloru_with_opacity(text_color, 60))
|
||||
}
|
||||
}
|
||||
|
||||
impl button::Theme for PhenomenonSecondaryButtonTheme {
|
||||
fn background(&self, button_state: button::State, _appearance: &Appearance) -> Option<Fill> {
|
||||
match button_state {
|
||||
button::State::Default => None,
|
||||
button::State::Hovered | button::State::Pressed => {
|
||||
Some(PhenomenonStyle::segmented_control_background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(&self, _background: Option<Fill>, _appearance: &Appearance) -> ColorU {
|
||||
PhenomenonStyle::foreground()
|
||||
}
|
||||
|
||||
fn border(&self, _appearance: &Appearance) -> Option<ColorU> {
|
||||
Some(PhenomenonStyle::subtle_border())
|
||||
}
|
||||
|
||||
fn keyboard_shortcut_border(&self, text_color: ColorU, _: &Appearance) -> Option<ColorU> {
|
||||
Some(coloru_with_opacity(text_color, 60))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StepStatus {
|
||||
pub current_step: u8,
|
||||
pub total_steps: u8,
|
||||
}
|
||||
|
||||
impl StepStatus {
|
||||
pub fn new(current_step: u8, total_steps: u8) -> Self {
|
||||
Self {
|
||||
current_step,
|
||||
total_steps,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Button {
|
||||
pub text: Cow<'static, str>,
|
||||
pub keystroke: Option<Keystroke>,
|
||||
pub handler: MouseEventHandler,
|
||||
}
|
||||
|
||||
impl Button {
|
||||
pub fn next(handler: MouseEventHandler) -> Self {
|
||||
Self {
|
||||
text: Cow::Borrowed("Next"),
|
||||
keystroke: Some(Keystroke {
|
||||
key: "enter".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
handler,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkbox with a label and click handler.
|
||||
pub struct Checkbox {
|
||||
pub label: Cow<'static, str>,
|
||||
pub checked: bool,
|
||||
pub handler: MouseEventHandler,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OnboardingCallout {
|
||||
right_button: ButtonComponent,
|
||||
left_button: ButtonComponent,
|
||||
checkbox_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct Params {
|
||||
/// The title of the callout.
|
||||
pub title: Cow<'static, str>,
|
||||
/// The body text of the callout.
|
||||
pub text: Cow<'static, str>,
|
||||
/// Current step and total steps.
|
||||
pub step: StepStatus,
|
||||
pub right_button: Button,
|
||||
/// Optional configuration.
|
||||
pub options: Options,
|
||||
}
|
||||
|
||||
impl ui_components::Params for Params {
|
||||
type Options<'a> = Options;
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
/// Optional left button, typically "Skip".
|
||||
pub left_button: Option<Button>,
|
||||
/// Optional checkbox for toggling settings.
|
||||
pub checkbox: Option<Checkbox>,
|
||||
}
|
||||
|
||||
impl ui_components::Options for Options {
|
||||
fn default(_appearance: &Appearance) -> Self {
|
||||
Self {
|
||||
left_button: None,
|
||||
checkbox: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for OnboardingCallout {
|
||||
type Params<'a> = Params;
|
||||
|
||||
fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element> {
|
||||
let Params {
|
||||
title,
|
||||
text,
|
||||
step,
|
||||
right_button,
|
||||
options,
|
||||
} = params;
|
||||
|
||||
let header = self.render_header(appearance, &title);
|
||||
let body = self.render_body(appearance, &text);
|
||||
// Take checkbox out before passing options to render_actions
|
||||
let mut options = options;
|
||||
let checkbox = options
|
||||
.checkbox
|
||||
.take()
|
||||
.map(|cb| self.render_checkbox(appearance, cb));
|
||||
let actions = self.render_actions(step, right_button, options, appearance);
|
||||
|
||||
let mut content = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_spacing(12.);
|
||||
|
||||
content.add_child(header);
|
||||
content.add_child(body);
|
||||
if let Some(checkbox_element) = checkbox {
|
||||
content.add_child(checkbox_element);
|
||||
}
|
||||
content.add_child(actions);
|
||||
|
||||
let content = content.finish();
|
||||
|
||||
let background = PhenomenonStyle::tinted_surface();
|
||||
let border_color = Fill::Solid(PhenomenonStyle::surface_border());
|
||||
|
||||
// Use lighter shadow on dark themes, darker shadow on light themes
|
||||
let background_luminance = relative_luminance(appearance.theme().background().into_solid());
|
||||
let is_light_theme = background_luminance > 0.2;
|
||||
let shadow_opacity = if is_light_theme { 20 } else { 35 };
|
||||
|
||||
let shadow = DropShadow {
|
||||
color: coloru_with_opacity(ColorU::black(), shadow_opacity),
|
||||
offset: vec2f(0., 10.),
|
||||
blur_radius: 20.,
|
||||
spread_radius: 0.,
|
||||
};
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(content)
|
||||
.with_uniform_padding(CALLOUT_PADDING)
|
||||
.with_background(background)
|
||||
.with_border(Border::all(CALLOUT_BORDER_WIDTH).with_border_fill(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
CALLOUT_CORNER_RADIUS,
|
||||
)))
|
||||
.with_drop_shadow(shadow)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(CALLOUT_WIDTH)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl OnboardingCallout {
|
||||
fn render_header(&self, appearance: &Appearance, title: &str) -> Box<dyn Element> {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.paragraph(title.to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(PhenomenonStyle::foreground()),
|
||||
font_size: Some(16.),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_body(&self, appearance: &Appearance, text: &str) -> Box<dyn Element> {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.paragraph(text.to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(PhenomenonStyle::body_text()),
|
||||
font_size: Some(13.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_checkbox(&self, appearance: &Appearance, checkbox: Checkbox) -> Box<dyn Element> {
|
||||
let checkbox_size = Some(12.);
|
||||
let corner_radius = CornerRadius::with_all(Radius::Pixels(2.));
|
||||
let foreground_color = PhenomenonStyle::foreground();
|
||||
let subtle_border = Fill::Solid(PhenomenonStyle::subtle_border());
|
||||
|
||||
let checkbox_element = WarpCheckbox::new(
|
||||
self.checkbox_mouse_state.clone(),
|
||||
UiComponentStyles {
|
||||
font_size: checkbox_size,
|
||||
border_color: Some(Fill::Solid(foreground_color).into()),
|
||||
font_color: Some(foreground_color),
|
||||
border_width: Some(1.),
|
||||
border_radius: Some(corner_radius),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
Some(UiComponentStyles {
|
||||
font_size: checkbox_size,
|
||||
background: Some(Fill::Solid(foreground_color).into()),
|
||||
border_color: Some(Fill::Solid(foreground_color).into()),
|
||||
font_color: Some(PhenomenonStyle::background()),
|
||||
border_radius: Some(corner_radius),
|
||||
..Default::default()
|
||||
}),
|
||||
Some(UiComponentStyles {
|
||||
font_size: checkbox_size,
|
||||
border_color: Some(subtle_border.into()),
|
||||
font_color: Some(PhenomenonStyle::subtle_border()),
|
||||
border_width: Some(1.),
|
||||
border_radius: Some(corner_radius),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.check(checkbox.checked)
|
||||
.build()
|
||||
.on_click(checkbox.handler)
|
||||
.finish();
|
||||
|
||||
let label = appearance
|
||||
.ui_builder()
|
||||
.paragraph(checkbox.label.to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(PhenomenonStyle::label_text()),
|
||||
font_size: Some(12.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(checkbox_element)
|
||||
.with_child(label)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_status_dots(&self, step: StepStatus) -> Box<dyn Element> {
|
||||
const DOT_SIZE: f32 = 8.;
|
||||
const DOT_SPACING: f32 = 4.;
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(DOT_SPACING);
|
||||
|
||||
for i in 0..step.total_steps {
|
||||
let is_current = i == step.current_step;
|
||||
let dot_color = if is_current {
|
||||
PhenomenonStyle::surface_border()
|
||||
} else {
|
||||
PhenomenonStyle::subtle_border()
|
||||
};
|
||||
|
||||
let dot = ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_background_color(dot_color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(DOT_SIZE / 2.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(DOT_SIZE)
|
||||
.with_height(DOT_SIZE)
|
||||
.finish();
|
||||
|
||||
row.add_child(dot);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_actions(
|
||||
&self,
|
||||
step: StepStatus,
|
||||
right_button: Button,
|
||||
mut options: Options,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let right_button_text = right_button.text.clone();
|
||||
|
||||
let right_button = self.right_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label(right_button_text),
|
||||
theme: &PhenomenonPrimaryButtonTheme,
|
||||
options: button::Options {
|
||||
on_click: Some(right_button.handler),
|
||||
keystroke: right_button.keystroke.clone(),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let status_dots = self.render_status_dots(step);
|
||||
|
||||
let mut buttons_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
|
||||
if let Some(left_button) = options.left_button.take() {
|
||||
let left_button_text = left_button.text.clone();
|
||||
|
||||
let left_button_element = self.left_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label(left_button_text),
|
||||
theme: &PhenomenonSecondaryButtonTheme,
|
||||
options: button::Options {
|
||||
on_click: Some(left_button.handler),
|
||||
keystroke: left_button.keystroke.clone(),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
buttons_row.add_child(
|
||||
Container::new(left_button_element)
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
buttons_row.add_child(right_button);
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_child(status_dots)
|
||||
.with_child(buttons_row.finish())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Onboarding library crate
|
||||
|
||||
mod agent_onboarding_view;
|
||||
pub mod callout;
|
||||
mod model;
|
||||
pub mod slides;
|
||||
pub mod telemetry;
|
||||
|
||||
/// The user's intention selected during onboarding slides.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OnboardingIntention {
|
||||
Terminal,
|
||||
AgentDrivenDevelopment,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OnboardingIntention {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
OnboardingIntention::AgentDrivenDevelopment => write!(f, "agent_driven"),
|
||||
OnboardingIntention::Terminal => write!(f, "terminal"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use callout::{OnboardingCalloutView, OnboardingKeybindings};
|
||||
|
||||
/// User-facing names of the AI features enabled when the agent intention is selected.
|
||||
/// Shared by the intention slide's agent card checklist and the login slide's
|
||||
/// skip-login confirmation dialog so the two always stay in sync.
|
||||
pub const AI_FEATURES: &[&str] = &[
|
||||
"Warp agents",
|
||||
"Oz cloud agents platform",
|
||||
"Next command predictions",
|
||||
"Prompt suggestions",
|
||||
"Codebase context",
|
||||
"Remote control with Claude Code, Codex, and other agents",
|
||||
"Agents over SSH",
|
||||
];
|
||||
|
||||
/// User-facing names of the Warp Drive features enabled when the terminal
|
||||
/// intention is selected with Warp Drive turned on. Shared by the login slide's
|
||||
/// skip-login confirmation dialog so the list stays in sync with any future
|
||||
/// surfaces that need it.
|
||||
pub const WARP_DRIVE_FEATURES: &[&str] = &["Warp Drive", "Session Sharing"];
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "bin")] {
|
||||
mod telemetry_provider;
|
||||
pub use telemetry_provider::MockTelemetryContextProvider;
|
||||
}
|
||||
}
|
||||
|
||||
pub mod components;
|
||||
mod visuals;
|
||||
|
||||
/// The default mode for new sessions, chosen during onboarding.
|
||||
/// Mapped to `DefaultSessionMode` at the application boundary.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SessionDefault {
|
||||
#[default]
|
||||
Agent,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionDefault {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SessionDefault::Agent => write!(f, "agent"),
|
||||
SessionDefault::Terminal => write!(f, "terminal"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use agent_onboarding_view::{AgentOnboardingAction, AgentOnboardingEvent, AgentOnboardingView};
|
||||
pub use model::{OnboardingAuthState, SelectedSettings, UICustomizationSettings};
|
||||
pub use slides::ProjectOnboardingSettings;
|
||||
pub use telemetry::OnboardingEvent;
|
||||
|
||||
pub fn init(app: &mut warpui::AppContext) {
|
||||
agent_onboarding_view::init(app);
|
||||
callout::init(app);
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
use crate::slides::{
|
||||
AgentAutonomy, AgentDevelopmentSettings, OnboardingModelInfo, ProjectOnboardingSettings,
|
||||
};
|
||||
use crate::telemetry::OnboardingEvent;
|
||||
use crate::OnboardingIntention;
|
||||
use ai::LLMId;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
/// UI customization settings chosen during the "Customize your UI" onboarding slide.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UICustomizationSettings {
|
||||
pub use_vertical_tabs: bool,
|
||||
pub show_conversation_history: bool,
|
||||
pub show_project_explorer: bool,
|
||||
pub show_global_search: bool,
|
||||
pub show_warp_drive: bool,
|
||||
pub show_code_review_button: bool,
|
||||
}
|
||||
|
||||
impl UICustomizationSettings {
|
||||
/// Defaults for agent-first development (all features enabled).
|
||||
pub fn agent_defaults() -> Self {
|
||||
Self {
|
||||
use_vertical_tabs: true,
|
||||
show_conversation_history: true,
|
||||
show_project_explorer: true,
|
||||
show_global_search: true,
|
||||
show_warp_drive: true,
|
||||
show_code_review_button: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Defaults for terminal mode (all features disabled).
|
||||
pub fn terminal_defaults() -> Self {
|
||||
Self {
|
||||
use_vertical_tabs: false,
|
||||
show_conversation_history: false,
|
||||
show_project_explorer: false,
|
||||
show_global_search: false,
|
||||
show_warp_drive: false,
|
||||
show_code_review_button: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if any tools-panel sub-setting visible for the given
|
||||
/// intention is enabled. In terminal mode the conversation-history chip is
|
||||
/// hidden, so it does not count.
|
||||
pub fn tools_panel_enabled(&self, intention: &OnboardingIntention) -> bool {
|
||||
let conversation_visible = matches!(intention, OnboardingIntention::AgentDrivenDevelopment);
|
||||
(conversation_visible && self.show_conversation_history)
|
||||
|| self.show_project_explorer
|
||||
|| self.show_global_search
|
||||
|| self.show_warp_drive
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OnboardingAuthState {
|
||||
LoggedOut,
|
||||
FreeUser,
|
||||
PayingUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SelectedSettings {
|
||||
Terminal {
|
||||
ui_customization: Option<UICustomizationSettings>,
|
||||
cli_agent_toolbar_enabled: bool,
|
||||
show_agent_notifications: bool,
|
||||
},
|
||||
AgentDrivenDevelopment {
|
||||
agent_settings: AgentDevelopmentSettings,
|
||||
project_settings: ProjectOnboardingSettings,
|
||||
ui_customization: Option<UICustomizationSettings>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SelectedSettings {
|
||||
pub fn is_ai_enabled(&self) -> bool {
|
||||
use warp_core::features::FeatureFlag;
|
||||
match self {
|
||||
SelectedSettings::AgentDrivenDevelopment { agent_settings, .. } => {
|
||||
!agent_settings.disable_oz
|
||||
}
|
||||
SelectedSettings::Terminal { .. } => {
|
||||
// With old onboarding (no OpenWarpNewSettingsModes), Terminal
|
||||
// intent still leaves AI enabled; with new onboarding,
|
||||
// Terminal intent explicitly disables AI.
|
||||
!FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_warp_drive_enabled(&self) -> bool {
|
||||
match self {
|
||||
SelectedSettings::AgentDrivenDevelopment {
|
||||
ui_customization, ..
|
||||
} => ui_customization
|
||||
.as_ref()
|
||||
.map(|ui| ui.show_warp_drive)
|
||||
.unwrap_or(true),
|
||||
SelectedSettings::Terminal {
|
||||
ui_customization, ..
|
||||
} => ui_customization
|
||||
.as_ref()
|
||||
.map(|ui| ui.show_warp_drive)
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum OnboardingStep {
|
||||
Intro,
|
||||
Intention,
|
||||
Customize,
|
||||
Agent,
|
||||
ThirdParty,
|
||||
Project,
|
||||
ThemePicker,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum OnboardingStateEvent {
|
||||
ModelsUpdated,
|
||||
SelectedSlideChanged,
|
||||
IntentionChanged,
|
||||
Completed,
|
||||
UpgradeRequested,
|
||||
AuthStateChanged,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OnboardingStateModel {
|
||||
step: OnboardingStep,
|
||||
intention: OnboardingIntention,
|
||||
agent_settings: AgentDevelopmentSettings,
|
||||
project_settings: ProjectOnboardingSettings,
|
||||
ui_customization: UICustomizationSettings,
|
||||
models: Vec<OnboardingModelInfo>,
|
||||
/// Whether the workspace enforces autonomy settings, hiding the user selection UI.
|
||||
workspace_enforces_autonomy: bool,
|
||||
/// Whether the AgentView feature flag is enabled.
|
||||
agent_modality_enabled: bool,
|
||||
/// Whether the user is in the FreeUserNoAi experiment group (and is free tier).
|
||||
/// When true, the Agent Driven Development option on the intention slide is locked
|
||||
/// behind an upgrade CTA.
|
||||
free_user_no_ai_experiment: bool,
|
||||
/// Yearly price per month in USD cents for the agent plan badge.
|
||||
/// When `None`, falls back to a hardcoded default ($18/mo).
|
||||
agent_price_cents: Option<i32>,
|
||||
/// Auth / billing state of the user.
|
||||
auth_state: OnboardingAuthState,
|
||||
}
|
||||
|
||||
impl OnboardingStateModel {
|
||||
/// Creates a new OnboardingStateModel.
|
||||
pub(crate) fn new(
|
||||
models: Vec<OnboardingModelInfo>,
|
||||
default_model_id: LLMId,
|
||||
workspace_enforces_autonomy: bool,
|
||||
agent_modality_enabled: bool,
|
||||
free_user_no_ai_experiment: bool,
|
||||
agent_price_cents: Option<i32>,
|
||||
auth_state: OnboardingAuthState,
|
||||
) -> Self {
|
||||
Self {
|
||||
step: OnboardingStep::Intro,
|
||||
intention: OnboardingIntention::AgentDrivenDevelopment,
|
||||
agent_settings: AgentDevelopmentSettings::new(default_model_id),
|
||||
project_settings: ProjectOnboardingSettings::default(),
|
||||
ui_customization: UICustomizationSettings::agent_defaults(),
|
||||
models,
|
||||
workspace_enforces_autonomy,
|
||||
agent_modality_enabled,
|
||||
free_user_no_ai_experiment,
|
||||
agent_price_cents,
|
||||
auth_state,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auth_state(&self) -> OnboardingAuthState {
|
||||
self.auth_state
|
||||
}
|
||||
|
||||
pub(crate) fn set_auth_state(
|
||||
&mut self,
|
||||
auth_state: OnboardingAuthState,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.auth_state == auth_state {
|
||||
return;
|
||||
}
|
||||
self.auth_state = auth_state;
|
||||
ctx.emit(OnboardingStateEvent::AuthStateChanged);
|
||||
}
|
||||
|
||||
pub(crate) fn settings(&self) -> SelectedSettings {
|
||||
use warp_core::features::FeatureFlag;
|
||||
let ui_customization = if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
Some(self.ui_customization.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match &self.intention {
|
||||
OnboardingIntention::Terminal => SelectedSettings::Terminal {
|
||||
ui_customization,
|
||||
cli_agent_toolbar_enabled: self.agent_settings.cli_agent_toolbar_enabled,
|
||||
show_agent_notifications: self.agent_settings.show_agent_notifications,
|
||||
},
|
||||
OnboardingIntention::AgentDrivenDevelopment => {
|
||||
SelectedSettings::AgentDrivenDevelopment {
|
||||
agent_settings: AgentDevelopmentSettings {
|
||||
selected_model_id: self.agent_settings.selected_model_id.clone(),
|
||||
autonomy: if self.workspace_enforces_autonomy {
|
||||
None
|
||||
} else {
|
||||
self.agent_settings.autonomy
|
||||
},
|
||||
cli_agent_toolbar_enabled: self.agent_settings.cli_agent_toolbar_enabled,
|
||||
session_default: self.agent_settings.session_default,
|
||||
disable_oz: self.agent_settings.disable_oz,
|
||||
// Agent intention always has notifications enabled (no toggle shown).
|
||||
show_agent_notifications: true,
|
||||
},
|
||||
project_settings: self.project_settings.clone(),
|
||||
ui_customization,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn step(&self) -> OnboardingStep {
|
||||
self.step
|
||||
}
|
||||
|
||||
pub(crate) fn intention(&self) -> &OnboardingIntention {
|
||||
&self.intention
|
||||
}
|
||||
|
||||
pub(crate) fn agent_settings(&self) -> &AgentDevelopmentSettings {
|
||||
&self.agent_settings
|
||||
}
|
||||
|
||||
pub(crate) fn project_settings(&self) -> &ProjectOnboardingSettings {
|
||||
&self.project_settings
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_enforces_autonomy(&self) -> bool {
|
||||
self.workspace_enforces_autonomy
|
||||
}
|
||||
|
||||
pub(crate) fn agent_modality_enabled(&self) -> bool {
|
||||
self.agent_modality_enabled
|
||||
}
|
||||
|
||||
pub fn ui_customization(&self) -> &UICustomizationSettings {
|
||||
&self.ui_customization
|
||||
}
|
||||
|
||||
pub(crate) fn set_use_vertical_tabs(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
|
||||
if self.ui_customization.use_vertical_tabs == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "tab_styling".to_string(),
|
||||
value: if value { "vertical" } else { "horizontal" }.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.ui_customization.use_vertical_tabs = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_tools_panel_enabled(&mut self, enabled: bool, ctx: &mut ModelContext<Self>) {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "tools_panel".to_string(),
|
||||
value: if enabled { "enabled" } else { "disabled" }.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.ui_customization.show_conversation_history = enabled;
|
||||
self.ui_customization.show_project_explorer = enabled;
|
||||
self.ui_customization.show_global_search = enabled;
|
||||
self.ui_customization.show_warp_drive = enabled;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn free_user_no_ai_experiment(&self) -> bool {
|
||||
self.free_user_no_ai_experiment
|
||||
}
|
||||
|
||||
pub(crate) fn agent_price_badge(&self) -> String {
|
||||
const DEFAULT_AGENT_PRICE_CENTS: i32 = 1800;
|
||||
let cents = self.agent_price_cents.unwrap_or(DEFAULT_AGENT_PRICE_CENTS);
|
||||
format!("Starting at ${}/mo", cents / 100)
|
||||
}
|
||||
|
||||
pub(crate) fn set_agent_price_cents(
|
||||
&mut self,
|
||||
cents: Option<i32>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.agent_price_cents == cents {
|
||||
return;
|
||||
}
|
||||
self.agent_price_cents = cents;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_show_conversation_history(
|
||||
&mut self,
|
||||
value: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.ui_customization.show_conversation_history == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "conversation_history".to_string(),
|
||||
value: value.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.ui_customization.show_conversation_history = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_show_project_explorer(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
|
||||
if self.ui_customization.show_project_explorer == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "project_explorer".to_string(),
|
||||
value: value.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.ui_customization.show_project_explorer = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_show_global_search(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
|
||||
if self.ui_customization.show_global_search == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "global_search".to_string(),
|
||||
value: value.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.ui_customization.show_global_search = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_show_warp_drive(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
|
||||
if self.ui_customization.show_warp_drive == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "warp_drive".to_string(),
|
||||
value: value.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.ui_customization.show_warp_drive = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_cli_agent_toolbar_enabled(
|
||||
&mut self,
|
||||
value: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.agent_settings.cli_agent_toolbar_enabled == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "cli_agent_toolbar".to_string(),
|
||||
value: if value { "enabled" } else { "disabled" }.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.agent_settings.cli_agent_toolbar_enabled = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_show_agent_notifications(
|
||||
&mut self,
|
||||
value: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.agent_settings.show_agent_notifications == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "show_agent_notifications".to_string(),
|
||||
value: if value { "enabled" } else { "disabled" }.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.agent_settings.show_agent_notifications = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_show_code_review_button(
|
||||
&mut self,
|
||||
value: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.ui_customization.show_code_review_button == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "code_review".to_string(),
|
||||
value: if value { "enabled" } else { "disabled" }.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.ui_customization.show_code_review_button = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_disable_oz(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
|
||||
if self.agent_settings.disable_oz == value {
|
||||
return;
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "disable_oz".to_string(),
|
||||
value: value.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
self.agent_settings.disable_oz = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_free_user_no_ai_experiment(
|
||||
&mut self,
|
||||
value: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.free_user_no_ai_experiment == value {
|
||||
return;
|
||||
}
|
||||
self.free_user_no_ai_experiment = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_workspace_enforces_autonomy(
|
||||
&mut self,
|
||||
value: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.workspace_enforces_autonomy == value {
|
||||
return;
|
||||
}
|
||||
self.workspace_enforces_autonomy = value;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn models(&self) -> &Vec<OnboardingModelInfo> {
|
||||
&self.models
|
||||
}
|
||||
|
||||
fn set_intention(&mut self, intention: OnboardingIntention, ctx: &mut ModelContext<Self>) {
|
||||
if self.intention == intention {
|
||||
return;
|
||||
}
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "intention".to_string(),
|
||||
value: intention.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
self.intention = intention;
|
||||
// Reset UI customization to defaults for the new intention.
|
||||
self.ui_customization = match intention {
|
||||
OnboardingIntention::AgentDrivenDevelopment => {
|
||||
UICustomizationSettings::agent_defaults()
|
||||
}
|
||||
OnboardingIntention::Terminal => UICustomizationSettings::terminal_defaults(),
|
||||
};
|
||||
// Reset notifications default based on intention.
|
||||
self.agent_settings.show_agent_notifications =
|
||||
matches!(intention, OnboardingIntention::AgentDrivenDevelopment);
|
||||
ctx.emit(OnboardingStateEvent::IntentionChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_intention_terminal(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_intention(OnboardingIntention::Terminal, ctx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_intention_agent_driven_development(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_intention(OnboardingIntention::AgentDrivenDevelopment, ctx);
|
||||
}
|
||||
|
||||
pub(crate) fn is_model_disabled(&self, model_id: &LLMId) -> bool {
|
||||
self.models
|
||||
.iter()
|
||||
.find(|m| &m.id == model_id)
|
||||
.is_some_and(|m| m.requires_upgrade)
|
||||
}
|
||||
|
||||
pub(crate) fn request_upgrade(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
ctx.emit(OnboardingStateEvent::UpgradeRequested);
|
||||
}
|
||||
|
||||
pub(crate) fn on_user_selected_model(&mut self, model_id: LLMId, ctx: &mut ModelContext<Self>) {
|
||||
if self.agent_settings.selected_model_id == model_id {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.is_model_disabled(&model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "model".to_string(),
|
||||
value: model_id.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
self.agent_settings.selected_model_id = model_id;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Updates the list of available models.
|
||||
pub(crate) fn set_models(
|
||||
&mut self,
|
||||
models: Vec<OnboardingModelInfo>,
|
||||
default_model_id: LLMId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
// If the user is past the agent slide, don't change the agent model from underneath them.
|
||||
// When the new settings modes flag is on, ThemePicker comes after the agent slides
|
||||
// so it must also be guarded.
|
||||
let is_past_agent_slide = if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
matches!(
|
||||
self.step,
|
||||
OnboardingStep::ThirdParty | OnboardingStep::ThemePicker
|
||||
)
|
||||
} else {
|
||||
matches!(self.step, OnboardingStep::Project)
|
||||
};
|
||||
if is_past_agent_slide {
|
||||
return;
|
||||
}
|
||||
|
||||
self.agent_settings.selected_model_id = default_model_id.clone();
|
||||
|
||||
self.models = models;
|
||||
ctx.emit(OnboardingStateEvent::ModelsUpdated);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_agent_autonomy(
|
||||
&mut self,
|
||||
autonomy: AgentAutonomy,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.workspace_enforces_autonomy || self.agent_settings.autonomy == Some(autonomy) {
|
||||
return;
|
||||
}
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "autonomy".to_string(),
|
||||
value: autonomy.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
self.agent_settings.autonomy = Some(autonomy);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_project_selected_local_folder(
|
||||
&mut self,
|
||||
path: Option<String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if path.is_some() {
|
||||
send_telemetry_from_ctx!(OnboardingEvent::FolderSelected, ctx);
|
||||
}
|
||||
self.project_settings = ProjectOnboardingSettings::from_path(path);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_project_initialize_projects_automatically(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let ProjectOnboardingSettings::Project {
|
||||
initialize_projects_automatically,
|
||||
..
|
||||
} = &mut self.project_settings
|
||||
{
|
||||
let new_value = !*initialize_projects_automatically;
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "initialize_project".to_string(),
|
||||
value: new_value.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
*initialize_projects_automatically = new_value;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn send_completion_telemetry(&self, ctx: &mut ModelContext<Self>) {
|
||||
let (intention, model, autonomy) = match &self.intention {
|
||||
OnboardingIntention::Terminal => (self.intention.to_string(), None, None),
|
||||
OnboardingIntention::AgentDrivenDevelopment => (
|
||||
self.intention.to_string(),
|
||||
Some(self.agent_settings.selected_model_id.to_string()),
|
||||
self.agent_settings.autonomy.map(|x| x.to_string()),
|
||||
),
|
||||
};
|
||||
|
||||
let has_project_path = matches!(
|
||||
self.project_settings,
|
||||
ProjectOnboardingSettings::Project { .. }
|
||||
);
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::OnboardingSlidesCompleted {
|
||||
intention,
|
||||
model,
|
||||
autonomy,
|
||||
has_project_path,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn complete(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.send_completion_telemetry(ctx);
|
||||
ctx.emit(OnboardingStateEvent::Completed);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn back(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
use warp_core::features::FeatureFlag;
|
||||
let theme_picker_last = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
|
||||
|
||||
let prev = if theme_picker_last {
|
||||
match self.step {
|
||||
OnboardingStep::Intro => None,
|
||||
OnboardingStep::Intention => Some(OnboardingStep::Intro),
|
||||
OnboardingStep::Customize => Some(OnboardingStep::Intention),
|
||||
OnboardingStep::Agent => Some(OnboardingStep::Customize),
|
||||
OnboardingStep::ThirdParty => match self.intention {
|
||||
OnboardingIntention::Terminal => Some(OnboardingStep::Customize),
|
||||
OnboardingIntention::AgentDrivenDevelopment => Some(OnboardingStep::Agent),
|
||||
},
|
||||
OnboardingStep::Project => Some(OnboardingStep::ThirdParty),
|
||||
OnboardingStep::ThemePicker => Some(OnboardingStep::ThirdParty),
|
||||
}
|
||||
} else {
|
||||
match self.step {
|
||||
OnboardingStep::Intro => None,
|
||||
OnboardingStep::ThemePicker => Some(OnboardingStep::Intro),
|
||||
OnboardingStep::Intention => Some(OnboardingStep::ThemePicker),
|
||||
OnboardingStep::Customize => None,
|
||||
OnboardingStep::ThirdParty => None,
|
||||
OnboardingStep::Agent => Some(OnboardingStep::Intention),
|
||||
OnboardingStep::Project => Some(OnboardingStep::Agent),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(prev) = prev {
|
||||
send_telemetry_from_ctx!(OnboardingEvent::SlideNavigatedBack, ctx);
|
||||
self.set_step(prev, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn next(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
use warp_core::features::FeatureFlag;
|
||||
let theme_picker_last = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
|
||||
|
||||
let is_last_step = if theme_picker_last {
|
||||
matches!(self.step, OnboardingStep::ThemePicker)
|
||||
} else {
|
||||
matches!(self.step, OnboardingStep::Project)
|
||||
};
|
||||
if !is_last_step {
|
||||
send_telemetry_from_ctx!(OnboardingEvent::SlideNavigatedNext, ctx);
|
||||
}
|
||||
|
||||
if theme_picker_last {
|
||||
match self.step {
|
||||
OnboardingStep::Intro => self.set_step(OnboardingStep::Intention, ctx),
|
||||
OnboardingStep::Intention => self.set_step(OnboardingStep::Customize, ctx),
|
||||
OnboardingStep::Customize => match self.intention {
|
||||
OnboardingIntention::Terminal => self.set_step(OnboardingStep::ThirdParty, ctx),
|
||||
OnboardingIntention::AgentDrivenDevelopment => {
|
||||
self.set_step(OnboardingStep::Agent, ctx)
|
||||
}
|
||||
},
|
||||
OnboardingStep::Agent => self.set_step(OnboardingStep::ThirdParty, ctx),
|
||||
OnboardingStep::ThirdParty => self.set_step(OnboardingStep::ThemePicker, ctx),
|
||||
OnboardingStep::Project => self.set_step(OnboardingStep::ThemePicker, ctx),
|
||||
OnboardingStep::ThemePicker => {}
|
||||
}
|
||||
} else {
|
||||
match self.step {
|
||||
OnboardingStep::Intro => self.set_step(OnboardingStep::ThemePicker, ctx),
|
||||
OnboardingStep::ThemePicker => self.set_step(OnboardingStep::Intention, ctx),
|
||||
OnboardingStep::Intention => self.set_step(OnboardingStep::Agent, ctx),
|
||||
OnboardingStep::Customize => {}
|
||||
OnboardingStep::ThirdParty => {}
|
||||
OnboardingStep::Agent => self.set_step(OnboardingStep::Project, ctx),
|
||||
OnboardingStep::Project => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_step(&mut self, step: OnboardingStep, ctx: &mut ModelContext<Self>) {
|
||||
if self.step == step {
|
||||
return;
|
||||
}
|
||||
|
||||
self.step = step;
|
||||
|
||||
match step {
|
||||
OnboardingStep::Intro => {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SlideViewed {
|
||||
slide_name: "intro".to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
OnboardingStep::ThemePicker => {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SlideViewed {
|
||||
slide_name: "theme_picker".to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
OnboardingStep::Intention => {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SlideViewed {
|
||||
slide_name: "intention".to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
OnboardingStep::Customize => {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SlideViewed {
|
||||
slide_name: "customize".to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
OnboardingStep::Agent => {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SlideViewed {
|
||||
slide_name: "agent".to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
OnboardingStep::ThirdParty => {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SlideViewed {
|
||||
slide_name: "third_party".to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
OnboardingStep::Project => {
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SlideViewed {
|
||||
slide_name: "project".to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.emit(OnboardingStateEvent::SelectedSlideChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for OnboardingStateModel {
|
||||
type Event = OnboardingStateEvent;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
use crate::slides::progress_dots;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Container, CrossAxisAlignment, Empty, Flex, MainAxisSize, ParentElement, Shrinkable,
|
||||
},
|
||||
Element,
|
||||
};
|
||||
|
||||
pub fn onboarding_bottom_nav(
|
||||
appearance: &Appearance,
|
||||
step_index: usize,
|
||||
step_count: usize,
|
||||
back_button: Option<Box<dyn Element>>,
|
||||
next_button: Option<Box<dyn Element>>,
|
||||
) -> Box<dyn Element> {
|
||||
let dots = progress_dots::progress_dots(step_count, step_index, appearance);
|
||||
|
||||
let back_button = back_button.unwrap_or_else(|| Empty::new().finish());
|
||||
let next_button = next_button.unwrap_or_else(|| Empty::new().finish());
|
||||
|
||||
// Use equal-size flex slots on the left and right so the dots remain centered regardless of the
|
||||
// button widths.
|
||||
let left = Shrinkable::new(1., Align::new(back_button).left().finish()).finish();
|
||||
let right = Shrinkable::new(1., Align::new(next_button).right().finish()).finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(left)
|
||||
.with_child(dots)
|
||||
.with_child(right)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
use super::toggle_card::{render_toggle_card, ChipSpec, ToggleCardSpec};
|
||||
use super::OnboardingSlide;
|
||||
use crate::model::{OnboardingStateEvent, OnboardingStateModel, UICustomizationSettings};
|
||||
use crate::slides::{bottom_nav, layout, slide_content};
|
||||
use crate::visuals::{intention_terminal_visual, intention_visual};
|
||||
use crate::OnboardingIntention;
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
|
||||
use warpui::prelude::Align;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ClippedScrollStateHandle, Container, CrossAxisAlignment, Flex, FormattedTextElement,
|
||||
MainAxisSize, MouseStateHandle, ParentElement,
|
||||
},
|
||||
fonts::Weight,
|
||||
keymap::Keystroke,
|
||||
text_layout::TextAlignment,
|
||||
ui_components::components::{UiComponent as _, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
/// Which setting card is currently selected (expanded).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SettingCard {
|
||||
TabStyling,
|
||||
ToolsPanel,
|
||||
CodeReview,
|
||||
}
|
||||
|
||||
/// Sub-settings within the tools panel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ToolsPanelSubSetting {
|
||||
ConversationHistory,
|
||||
ProjectExplorer,
|
||||
GlobalSearch,
|
||||
WarpDrive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CustomizeSlideAction {
|
||||
SelectSettingCard { card_index: usize },
|
||||
SetTabStylingVertical { vertical: bool },
|
||||
SetToolsPanelEnabled { enabled: bool },
|
||||
ToggleToolsSubSetting { setting: ToolsPanelSubSetting },
|
||||
HoverToolsChip { setting: ToolsPanelSubSetting },
|
||||
SetCodeReviewEnabled { enabled: bool },
|
||||
BackClicked,
|
||||
NextClicked,
|
||||
}
|
||||
|
||||
pub struct CustomizeUISlide {
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
selected_setting: Option<SettingCard>,
|
||||
/// The last-hovered tools panel chip; persists until a different chip is hovered
|
||||
/// or a different card is selected.
|
||||
hovered_chip: Option<ToolsPanelSubSetting>,
|
||||
// Mouse states for setting cards
|
||||
tab_styling_mouse_state: MouseStateHandle,
|
||||
tools_panel_mouse_state: MouseStateHandle,
|
||||
code_review_mouse_state: MouseStateHandle,
|
||||
// Mouse states for segmented control options (2 per card)
|
||||
tab_seg_left_mouse: MouseStateHandle,
|
||||
tab_seg_right_mouse: MouseStateHandle,
|
||||
tools_seg_left_mouse: MouseStateHandle,
|
||||
tools_seg_right_mouse: MouseStateHandle,
|
||||
code_seg_left_mouse: MouseStateHandle,
|
||||
code_seg_right_mouse: MouseStateHandle,
|
||||
// Mouse states for tools panel chip buttons
|
||||
chip_conversation_mouse: MouseStateHandle,
|
||||
chip_file_explorer_mouse: MouseStateHandle,
|
||||
chip_global_search_mouse: MouseStateHandle,
|
||||
chip_warp_drive_mouse: MouseStateHandle,
|
||||
// Buttons
|
||||
back_button: button::Button,
|
||||
next_button: button::Button,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl CustomizeUISlide {
|
||||
pub(crate) fn new(
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&onboarding_state, |me, _model, event, ctx| {
|
||||
if matches!(event, OnboardingStateEvent::IntentionChanged) {
|
||||
me.selected_setting = None;
|
||||
me.hovered_chip = None;
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
onboarding_state,
|
||||
selected_setting: None,
|
||||
hovered_chip: None,
|
||||
tab_styling_mouse_state: MouseStateHandle::default(),
|
||||
tools_panel_mouse_state: MouseStateHandle::default(),
|
||||
code_review_mouse_state: MouseStateHandle::default(),
|
||||
tab_seg_left_mouse: MouseStateHandle::default(),
|
||||
tab_seg_right_mouse: MouseStateHandle::default(),
|
||||
tools_seg_left_mouse: MouseStateHandle::default(),
|
||||
tools_seg_right_mouse: MouseStateHandle::default(),
|
||||
code_seg_left_mouse: MouseStateHandle::default(),
|
||||
code_seg_right_mouse: MouseStateHandle::default(),
|
||||
chip_conversation_mouse: MouseStateHandle::default(),
|
||||
chip_file_explorer_mouse: MouseStateHandle::default(),
|
||||
chip_global_search_mouse: MouseStateHandle::default(),
|
||||
chip_warp_drive_mouse: MouseStateHandle::default(),
|
||||
back_button: button::Button::default(),
|
||||
next_button: button::Button::default(),
|
||||
scroll_state: ClippedScrollStateHandle::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_intention(&self, app: &AppContext) -> OnboardingIntention {
|
||||
*self.onboarding_state.as_ref(app).intention()
|
||||
}
|
||||
|
||||
fn model_ui_customization(&self, app: &AppContext) -> UICustomizationSettings {
|
||||
self.onboarding_state.as_ref(app).ui_customization().clone()
|
||||
}
|
||||
|
||||
fn render_content(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
intention: OnboardingIntention,
|
||||
ui: &UICustomizationSettings,
|
||||
) -> Box<dyn Element> {
|
||||
let bottom_nav = Align::new(self.render_bottom_nav(appearance, intention)).finish();
|
||||
|
||||
slide_content::onboarding_slide_content(
|
||||
vec![
|
||||
Align::new(self.render_header(appearance)).left().finish(),
|
||||
self.render_setting_cards(appearance, intention, ui),
|
||||
],
|
||||
bottom_nav,
|
||||
self.scroll_state.clone(),
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let title = appearance
|
||||
.ui_builder()
|
||||
.paragraph("Customize your Warp")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(36.),
|
||||
font_weight: Some(Weight::Medium),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let subtitle = FormattedTextElement::from_str(
|
||||
"Tailor your features and UI to your working style.",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(internal_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background().into_solid(),
|
||||
))
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Left)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(
|
||||
Container::new(subtitle)
|
||||
.with_margin_top(16.)
|
||||
.with_margin_bottom(40.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
// --- Setting cards ---
|
||||
|
||||
fn render_setting_cards(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
intention: OnboardingIntention,
|
||||
ui: &UICustomizationSettings,
|
||||
) -> Box<dyn Element> {
|
||||
let tab_card = self.render_tab_styling_card(appearance, ui);
|
||||
let tools_card = self.render_tools_panel_card(appearance, intention, ui);
|
||||
let code_card = self.render_code_review_card(appearance, ui);
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(12.)
|
||||
.with_child(tab_card)
|
||||
.with_child(tools_card)
|
||||
.with_child(code_card)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_tab_styling_card(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
ui: &UICustomizationSettings,
|
||||
) -> Box<dyn Element> {
|
||||
let is_selected = self.selected_setting == Some(SettingCard::TabStyling);
|
||||
|
||||
render_toggle_card(
|
||||
appearance,
|
||||
ToggleCardSpec {
|
||||
title: "Tab styling",
|
||||
is_expanded: is_selected,
|
||||
is_left_selected: ui.use_vertical_tabs,
|
||||
left_label: "Vertical",
|
||||
right_label: "Horizontal",
|
||||
card_mouse_state: self.tab_styling_mouse_state.clone(),
|
||||
on_expand: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SelectSettingCard {
|
||||
card_index: 0,
|
||||
});
|
||||
}),
|
||||
left_mouse: self.tab_seg_left_mouse.clone(),
|
||||
right_mouse: self.tab_seg_right_mouse.clone(),
|
||||
on_left: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SetTabStylingVertical {
|
||||
vertical: true,
|
||||
});
|
||||
}),
|
||||
on_right: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SetTabStylingVertical {
|
||||
vertical: false,
|
||||
});
|
||||
}),
|
||||
chips: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
fn render_tools_panel_card(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
intention: OnboardingIntention,
|
||||
ui: &UICustomizationSettings,
|
||||
) -> Box<dyn Element> {
|
||||
let is_selected = self.selected_setting == Some(SettingCard::ToolsPanel);
|
||||
let is_agent = matches!(intention, OnboardingIntention::AgentDrivenDevelopment);
|
||||
|
||||
let mut chips = vec![];
|
||||
|
||||
if ui.tools_panel_enabled(&intention) {
|
||||
// Conversation history chip is only shown for the agent intention.
|
||||
if is_agent {
|
||||
chips.push(ChipSpec {
|
||||
label: "Conversation history",
|
||||
is_enabled: ui.show_conversation_history,
|
||||
mouse_state: self.chip_conversation_mouse.clone(),
|
||||
on_click: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
|
||||
setting: ToolsPanelSubSetting::ConversationHistory,
|
||||
});
|
||||
}),
|
||||
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
|
||||
if is_hovered {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
|
||||
setting: ToolsPanelSubSetting::ConversationHistory,
|
||||
});
|
||||
}
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
chips.push(ChipSpec {
|
||||
label: "File explorer",
|
||||
is_enabled: ui.show_project_explorer,
|
||||
mouse_state: self.chip_file_explorer_mouse.clone(),
|
||||
on_click: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
|
||||
setting: ToolsPanelSubSetting::ProjectExplorer,
|
||||
});
|
||||
}),
|
||||
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
|
||||
if is_hovered {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
|
||||
setting: ToolsPanelSubSetting::ProjectExplorer,
|
||||
});
|
||||
}
|
||||
})),
|
||||
});
|
||||
|
||||
chips.push(ChipSpec {
|
||||
label: "Global file search",
|
||||
is_enabled: ui.show_global_search,
|
||||
mouse_state: self.chip_global_search_mouse.clone(),
|
||||
on_click: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
|
||||
setting: ToolsPanelSubSetting::GlobalSearch,
|
||||
});
|
||||
}),
|
||||
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
|
||||
if is_hovered {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
|
||||
setting: ToolsPanelSubSetting::GlobalSearch,
|
||||
});
|
||||
}
|
||||
})),
|
||||
});
|
||||
|
||||
chips.push(ChipSpec {
|
||||
label: "Warp Drive",
|
||||
is_enabled: ui.show_warp_drive,
|
||||
mouse_state: self.chip_warp_drive_mouse.clone(),
|
||||
on_click: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
|
||||
setting: ToolsPanelSubSetting::WarpDrive,
|
||||
});
|
||||
}),
|
||||
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
|
||||
if is_hovered {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
|
||||
setting: ToolsPanelSubSetting::WarpDrive,
|
||||
});
|
||||
}
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
render_toggle_card(
|
||||
appearance,
|
||||
ToggleCardSpec {
|
||||
title: "Tools panel",
|
||||
is_expanded: is_selected,
|
||||
is_left_selected: ui.tools_panel_enabled(&intention),
|
||||
left_label: "Enabled",
|
||||
right_label: "Disabled",
|
||||
card_mouse_state: self.tools_panel_mouse_state.clone(),
|
||||
on_expand: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SelectSettingCard {
|
||||
card_index: 1,
|
||||
});
|
||||
}),
|
||||
left_mouse: self.tools_seg_left_mouse.clone(),
|
||||
right_mouse: self.tools_seg_right_mouse.clone(),
|
||||
on_left: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SetToolsPanelEnabled {
|
||||
enabled: true,
|
||||
});
|
||||
}),
|
||||
on_right: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SetToolsPanelEnabled {
|
||||
enabled: false,
|
||||
});
|
||||
}),
|
||||
chips,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn render_code_review_card(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
ui: &UICustomizationSettings,
|
||||
) -> Box<dyn Element> {
|
||||
let is_selected = self.selected_setting == Some(SettingCard::CodeReview);
|
||||
|
||||
render_toggle_card(
|
||||
appearance,
|
||||
ToggleCardSpec {
|
||||
title: "Code review",
|
||||
is_expanded: is_selected,
|
||||
is_left_selected: ui.show_code_review_button,
|
||||
left_label: "Enabled",
|
||||
right_label: "Disabled",
|
||||
card_mouse_state: self.code_review_mouse_state.clone(),
|
||||
on_expand: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SelectSettingCard {
|
||||
card_index: 2,
|
||||
});
|
||||
}),
|
||||
left_mouse: self.code_seg_left_mouse.clone(),
|
||||
right_mouse: self.code_seg_right_mouse.clone(),
|
||||
on_left: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SetCodeReviewEnabled {
|
||||
enabled: true,
|
||||
});
|
||||
}),
|
||||
on_right: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::SetCodeReviewEnabled {
|
||||
enabled: false,
|
||||
});
|
||||
}),
|
||||
chips: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// --- Bottom nav ---
|
||||
|
||||
fn render_bottom_nav(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
intention: OnboardingIntention,
|
||||
) -> 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(CustomizeSlideAction::BackClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let enter = Keystroke::parse("enter").unwrap_or_default();
|
||||
let next_button = self.next_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label("Next".into()),
|
||||
theme: &button::themes::Primary,
|
||||
options: button::Options {
|
||||
keystroke: Some(enter),
|
||||
on_click: Some(Box::new(|ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(CustomizeSlideAction::NextClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let is_terminal = matches!(intention, OnboardingIntention::Terminal);
|
||||
let (step_index, step_count) = if is_terminal { (1, 4) } else { (1, 5) };
|
||||
bottom_nav::onboarding_bottom_nav(
|
||||
appearance,
|
||||
step_index,
|
||||
step_count,
|
||||
Some(back_button),
|
||||
Some(next_button),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Visual (right column) ---
|
||||
|
||||
/// All bundled image paths used by the customize slide visual.
|
||||
/// Used for preloading into the asset cache.
|
||||
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
|
||||
// Welcome / default
|
||||
"async/png/onboarding/welcome_agent.png",
|
||||
"async/png/onboarding/welcome_terminal.png",
|
||||
// Agent intention
|
||||
"async/png/onboarding/agent_intention/customize_vertical_tabs.png",
|
||||
"async/png/onboarding/agent_intention/customize_horizontal_tabs.png",
|
||||
"async/png/onboarding/agent_intention/customize_tools_disabled_vertical.png",
|
||||
"async/png/onboarding/agent_intention/customize_tools_disabled_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/customize_conversation_vertical.png",
|
||||
"async/png/onboarding/agent_intention/customize_conversation_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/customize_fileexplorer_vertical.png",
|
||||
"async/png/onboarding/agent_intention/customize_fileexplorer_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/customize_filesearch_vertical.png",
|
||||
"async/png/onboarding/agent_intention/customize_filesearch_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/customize_warpdrive_vertical.png",
|
||||
"async/png/onboarding/agent_intention/customize_warpdrive_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/customize_codereview_enabled_vertical.png",
|
||||
"async/png/onboarding/agent_intention/customize_codereview_enabled_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/customize_codereview_disabled_vertical.png",
|
||||
"async/png/onboarding/agent_intention/customize_codereview_disabled_horizontal.png",
|
||||
// Terminal intention
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_vertical_tabs.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_horizontal_tabs.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_fileexplorer_vertical.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_fileexplorer_horizontal.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_filesearch_vertical.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_filesearch_horizontal.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_warpdrive_vertical.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_warpdrive_horizontal.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_codereview_enabled.png",
|
||||
"async/png/onboarding/terminal_intention/terminal_codereview_disabled.png",
|
||||
];
|
||||
|
||||
/// Returns the image path for the current visual state.
|
||||
/// When `OpenWarpNewSettingsModes` is enabled, assets depend on the tab layout setting.
|
||||
fn visual_image_path(
|
||||
selected_setting: Option<SettingCard>,
|
||||
hovered_chip: Option<ToolsPanelSubSetting>,
|
||||
intention: OnboardingIntention,
|
||||
ui: &UICustomizationSettings,
|
||||
) -> &'static str {
|
||||
let is_agent = matches!(intention, OnboardingIntention::AgentDrivenDevelopment);
|
||||
let vertical = ui.use_vertical_tabs;
|
||||
match selected_setting {
|
||||
None => match intention {
|
||||
OnboardingIntention::AgentDrivenDevelopment => {
|
||||
"async/png/onboarding/welcome_agent.png"
|
||||
}
|
||||
OnboardingIntention::Terminal => "async/png/onboarding/welcome_terminal.png",
|
||||
},
|
||||
Some(SettingCard::TabStyling) => {
|
||||
if is_agent {
|
||||
if !ui.tools_panel_enabled(&intention) {
|
||||
if vertical {
|
||||
"async/png/onboarding/agent_intention/customize_tools_disabled_vertical.png"
|
||||
} else {
|
||||
"async/png/onboarding/agent_intention/customize_tools_disabled_horizontal.png"
|
||||
}
|
||||
} else if vertical {
|
||||
"async/png/onboarding/agent_intention/customize_vertical_tabs.png"
|
||||
} else {
|
||||
"async/png/onboarding/agent_intention/customize_horizontal_tabs.png"
|
||||
}
|
||||
} else if vertical {
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_vertical_tabs.png"
|
||||
} else {
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_horizontal_tabs.png"
|
||||
}
|
||||
}
|
||||
Some(SettingCard::ToolsPanel) => {
|
||||
if !ui.tools_panel_enabled(&intention) {
|
||||
// Terminal: tools disabled uses the same image as tab layout.
|
||||
if is_agent {
|
||||
if vertical {
|
||||
"async/png/onboarding/agent_intention/customize_tools_disabled_vertical.png"
|
||||
} else {
|
||||
"async/png/onboarding/agent_intention/customize_tools_disabled_horizontal.png"
|
||||
}
|
||||
} else if vertical {
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_vertical_tabs.png"
|
||||
} else {
|
||||
"async/png/onboarding/terminal_intention/terminal_customize_horizontal_tabs.png"
|
||||
}
|
||||
} else {
|
||||
// Default chip: conversation for agent, file explorer for terminal.
|
||||
let default_chip = if is_agent {
|
||||
ToolsPanelSubSetting::ConversationHistory
|
||||
} else {
|
||||
ToolsPanelSubSetting::ProjectExplorer
|
||||
};
|
||||
let chip = hovered_chip.unwrap_or(default_chip);
|
||||
if is_agent {
|
||||
match (chip, vertical) {
|
||||
(ToolsPanelSubSetting::ConversationHistory, true) => "async/png/onboarding/agent_intention/customize_conversation_vertical.png",
|
||||
(ToolsPanelSubSetting::ConversationHistory, false) => "async/png/onboarding/agent_intention/customize_conversation_horizontal.png",
|
||||
(ToolsPanelSubSetting::ProjectExplorer, true) => "async/png/onboarding/agent_intention/customize_fileexplorer_vertical.png",
|
||||
(ToolsPanelSubSetting::ProjectExplorer, false) => "async/png/onboarding/agent_intention/customize_fileexplorer_horizontal.png",
|
||||
(ToolsPanelSubSetting::GlobalSearch, true) => "async/png/onboarding/agent_intention/customize_filesearch_vertical.png",
|
||||
(ToolsPanelSubSetting::GlobalSearch, false) => "async/png/onboarding/agent_intention/customize_filesearch_horizontal.png",
|
||||
(ToolsPanelSubSetting::WarpDrive, true) => "async/png/onboarding/agent_intention/customize_warpdrive_vertical.png",
|
||||
(ToolsPanelSubSetting::WarpDrive, false) => "async/png/onboarding/agent_intention/customize_warpdrive_horizontal.png",
|
||||
}
|
||||
} else {
|
||||
// Terminal: no conversation chip; ConversationHistory falls through to file explorer.
|
||||
match (chip, vertical) {
|
||||
(ToolsPanelSubSetting::ConversationHistory | ToolsPanelSubSetting::ProjectExplorer, true) => "async/png/onboarding/terminal_intention/terminal_customize_fileexplorer_vertical.png",
|
||||
(ToolsPanelSubSetting::ConversationHistory | ToolsPanelSubSetting::ProjectExplorer, false) => "async/png/onboarding/terminal_intention/terminal_customize_fileexplorer_horizontal.png",
|
||||
(ToolsPanelSubSetting::GlobalSearch, true) => "async/png/onboarding/terminal_intention/terminal_customize_filesearch_vertical.png",
|
||||
(ToolsPanelSubSetting::GlobalSearch, false) => "async/png/onboarding/terminal_intention/terminal_customize_filesearch_horizontal.png",
|
||||
(ToolsPanelSubSetting::WarpDrive, true) => "async/png/onboarding/terminal_intention/terminal_customize_warpdrive_vertical.png",
|
||||
(ToolsPanelSubSetting::WarpDrive, false) => "async/png/onboarding/terminal_intention/terminal_customize_warpdrive_horizontal.png",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(SettingCard::CodeReview) => {
|
||||
if is_agent {
|
||||
match (ui.show_code_review_button, vertical) {
|
||||
(true, true) => "async/png/onboarding/agent_intention/customize_codereview_enabled_vertical.png",
|
||||
(true, false) => "async/png/onboarding/agent_intention/customize_codereview_enabled_horizontal.png",
|
||||
(false, true) => "async/png/onboarding/agent_intention/customize_codereview_disabled_vertical.png",
|
||||
(false, false) => "async/png/onboarding/agent_intention/customize_codereview_disabled_horizontal.png",
|
||||
}
|
||||
} else if ui.show_code_review_button {
|
||||
"async/png/onboarding/terminal_intention/terminal_codereview_enabled.png"
|
||||
} else {
|
||||
"async/png/onboarding/terminal_intention/terminal_codereview_disabled.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_visual(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
intention: OnboardingIntention,
|
||||
ui: &UICustomizationSettings,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
let path =
|
||||
Self::visual_image_path(self.selected_setting, self.hovered_chip, intention, ui);
|
||||
let fg_layout = match self.selected_setting {
|
||||
None => layout::FOREGROUND_LAYOUT_DEFAULT,
|
||||
Some(SettingCard::CodeReview) => layout::FOREGROUND_LAYOUT_CODE_REVIEW,
|
||||
_ => layout::FOREGROUND_LAYOUT_WIDE,
|
||||
};
|
||||
layout::onboarding_right_panel_with_bg(path, fg_layout)
|
||||
} else {
|
||||
let panel_background = internal_colors::neutral_2(theme);
|
||||
let neutral = internal_colors::neutral_4(theme);
|
||||
|
||||
let visual = if matches!(intention, OnboardingIntention::Terminal) {
|
||||
let neutral_highlight = internal_colors::neutral_6(theme);
|
||||
let accent = internal_colors::accent(theme);
|
||||
intention_terminal_visual(
|
||||
panel_background,
|
||||
neutral,
|
||||
neutral_highlight,
|
||||
accent.into_solid(),
|
||||
)
|
||||
} else {
|
||||
let blue = theme.ansi_fg_blue();
|
||||
let green = theme.ansi_fg_green();
|
||||
let yellow = theme.ansi_fg_yellow();
|
||||
intention_visual(panel_background, neutral, blue, green, yellow)
|
||||
};
|
||||
|
||||
Container::new(visual)
|
||||
.with_background_color(internal_colors::neutral_1(theme))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CustomizeUISlide {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for CustomizeUISlide {
|
||||
fn ui_name() -> &'static str {
|
||||
"CustomizeUISlide"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let intention = self.model_intention(app);
|
||||
let ui = self.model_ui_customization(app);
|
||||
|
||||
layout::static_left(
|
||||
|| self.render_content(appearance, intention, &ui),
|
||||
|| self.render_visual(appearance, intention, &ui),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomizeUISlide {
|
||||
fn select_setting_card(&mut self, card_index: usize, ctx: &mut ViewContext<Self>) {
|
||||
let card = match card_index {
|
||||
0 => SettingCard::TabStyling,
|
||||
1 => SettingCard::ToolsPanel,
|
||||
2 => SettingCard::CodeReview,
|
||||
_ => return,
|
||||
};
|
||||
// Only select — don't toggle. Clicking a different card replaces the selection.
|
||||
self.selected_setting = Some(card);
|
||||
// Reset chip hover when switching cards.
|
||||
self.hovered_chip = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn next(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.next(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl OnboardingSlide for CustomizeUISlide {
|
||||
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// Move setting selection up
|
||||
self.selected_setting = match self.selected_setting {
|
||||
Some(SettingCard::ToolsPanel) => Some(SettingCard::TabStyling),
|
||||
Some(SettingCard::CodeReview) => Some(SettingCard::ToolsPanel),
|
||||
_ => self.selected_setting,
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.selected_setting = match self.selected_setting {
|
||||
Some(SettingCard::TabStyling) => Some(SettingCard::ToolsPanel),
|
||||
Some(SettingCard::ToolsPanel) => Some(SettingCard::CodeReview),
|
||||
None => Some(SettingCard::TabStyling),
|
||||
other => other,
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn on_left(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
match self.selected_setting {
|
||||
Some(SettingCard::TabStyling) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_use_vertical_tabs(true, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Some(SettingCard::ToolsPanel) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_tools_panel_enabled(true, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Some(SettingCard::CodeReview) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_show_code_review_button(true, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_right(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
match self.selected_setting {
|
||||
Some(SettingCard::TabStyling) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_use_vertical_tabs(false, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Some(SettingCard::ToolsPanel) => {
|
||||
self.hovered_chip = None;
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_tools_panel_enabled(false, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Some(SettingCard::CodeReview) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_show_code_review_button(false, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.next(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for CustomizeUISlide {
|
||||
type Action = CustomizeSlideAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
CustomizeSlideAction::SelectSettingCard { card_index } => {
|
||||
self.select_setting_card(*card_index, ctx);
|
||||
}
|
||||
CustomizeSlideAction::SetTabStylingVertical { vertical } => {
|
||||
let value = *vertical;
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_use_vertical_tabs(value, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
CustomizeSlideAction::SetToolsPanelEnabled { enabled } => {
|
||||
let value = *enabled;
|
||||
if !value {
|
||||
self.hovered_chip = None;
|
||||
}
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_tools_panel_enabled(value, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
CustomizeSlideAction::HoverToolsChip { setting } => {
|
||||
self.hovered_chip = Some(*setting);
|
||||
ctx.notify();
|
||||
}
|
||||
CustomizeSlideAction::ToggleToolsSubSetting { setting } => {
|
||||
let setting = *setting;
|
||||
self.onboarding_state
|
||||
.update(ctx, |model, ctx| match setting {
|
||||
ToolsPanelSubSetting::ConversationHistory => {
|
||||
let current = model.ui_customization().show_conversation_history;
|
||||
model.set_show_conversation_history(!current, ctx);
|
||||
}
|
||||
ToolsPanelSubSetting::ProjectExplorer => {
|
||||
let current = model.ui_customization().show_project_explorer;
|
||||
model.set_show_project_explorer(!current, ctx);
|
||||
}
|
||||
ToolsPanelSubSetting::GlobalSearch => {
|
||||
let current = model.ui_customization().show_global_search;
|
||||
model.set_show_global_search(!current, ctx);
|
||||
}
|
||||
ToolsPanelSubSetting::WarpDrive => {
|
||||
let current = model.ui_customization().show_warp_drive;
|
||||
model.set_show_warp_drive(!current, ctx);
|
||||
}
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
CustomizeSlideAction::SetCodeReviewEnabled { enabled } => {
|
||||
let value = *enabled;
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_show_code_review_button(value, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
CustomizeSlideAction::BackClicked => {
|
||||
let onboarding_state = self.onboarding_state.clone();
|
||||
onboarding_state.update(ctx, |model, ctx| {
|
||||
model.back(ctx);
|
||||
});
|
||||
}
|
||||
CustomizeSlideAction::NextClicked => {
|
||||
self.next(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
use super::OnboardingSlide;
|
||||
use crate::model::OnboardingStateModel;
|
||||
use crate::slides::{bottom_nav, layout, slide_content};
|
||||
use crate::telemetry::OnboardingEvent;
|
||||
use crate::OnboardingIntention;
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
|
||||
use warpui::prelude::Align;
|
||||
use warpui::{
|
||||
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,
|
||||
};
|
||||
|
||||
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: warpui::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_warpui_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(
|
||||
warpui::elements::Text::new_inline(
|
||||
"Subscribe",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(fg_color)
|
||||
.with_style(warpui::fonts::Properties {
|
||||
weight: Weight::Semibold,
|
||||
style: warpui::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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
use super::OnboardingSlide;
|
||||
use crate::model::OnboardingStateModel;
|
||||
use crate::slides::{bottom_nav, layout, slide_content};
|
||||
use crate::visuals::{intention_terminal_visual, intention_visual};
|
||||
use crate::{OnboardingIntention, AI_FEATURES};
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
|
||||
use warpui::prelude::Align;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Radius,
|
||||
},
|
||||
fonts::Weight,
|
||||
keymap::Keystroke,
|
||||
platform::Cursor,
|
||||
text_layout::TextAlignment,
|
||||
ui_components::components::{UiComponent as _, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum IntentionSlideAction {
|
||||
SelectOption { index: usize },
|
||||
BackClicked,
|
||||
NextClicked,
|
||||
}
|
||||
|
||||
pub struct IntentionSlide {
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
agent_driven_development_mouse_state: MouseStateHandle,
|
||||
classic_terminal_mouse_state: MouseStateHandle,
|
||||
back_button: button::Button,
|
||||
next_button: button::Button,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl IntentionSlide {
|
||||
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
|
||||
Self {
|
||||
onboarding_state,
|
||||
agent_driven_development_mouse_state: MouseStateHandle::default(),
|
||||
classic_terminal_mouse_state: MouseStateHandle::default(),
|
||||
back_button: button::Button::default(),
|
||||
next_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) -> Box<dyn Element> {
|
||||
let bottom_nav = Align::new(self.render_bottom_nav(appearance, selected_index)).finish();
|
||||
|
||||
slide_content::onboarding_slide_content(
|
||||
vec![
|
||||
Align::new(self.render_header(appearance)).left().finish(),
|
||||
Align::new(self.render_options(appearance, selected_index)).finish(),
|
||||
],
|
||||
bottom_nav,
|
||||
self.scroll_state.clone(),
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let logo_fill = internal_colors::fg_overlay_4(theme);
|
||||
let logo = ConstrainedBox::new(Icon::WarpLogoLight.to_warpui_icon(logo_fill).finish())
|
||||
.with_width(64.)
|
||||
.with_height(64.)
|
||||
.finish();
|
||||
|
||||
let title = appearance
|
||||
.ui_builder()
|
||||
.paragraph("Welcome to Warp")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(36.),
|
||||
font_weight: Some(Weight::Medium),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let subtitle = FormattedTextElement::from_str(
|
||||
"How do you want to work?",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(internal_colors::text_sub(
|
||||
theme,
|
||||
theme.background().into_solid(),
|
||||
))
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Left)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
// Offset icon built in padding to left align icon with title.
|
||||
.with_child(Container::new(logo).with_margin_left(-7.).finish())
|
||||
.with_child(Container::new(title).with_margin_top(11.).finish())
|
||||
.with_child(Container::new(subtitle).with_margin_top(16.).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_options(&self, appearance: &Appearance, selected_index: usize) -> Box<dyn Element> {
|
||||
let agent_card = self.render_agent_card(
|
||||
appearance,
|
||||
selected_index == 0,
|
||||
self.agent_driven_development_mouse_state.clone(),
|
||||
);
|
||||
|
||||
let terminal_card = self.render_terminal_card(
|
||||
appearance,
|
||||
selected_index == 1,
|
||||
self.classic_terminal_mouse_state.clone(),
|
||||
);
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(Container::new(agent_card).with_margin_bottom(12.).finish())
|
||||
.with_child(terminal_card)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(38.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Shared chrome for an intention-slide option card. Applies the selected/unselected
|
||||
/// background + border + rounded corners, wires up hover/click, and emits the
|
||||
/// `SelectOption` action for the provided `index`.
|
||||
fn render_card_chrome(
|
||||
appearance: &Appearance,
|
||||
is_selected: bool,
|
||||
index: usize,
|
||||
mouse_state: MouseStateHandle,
|
||||
content: Box<dyn Element>,
|
||||
) -> Box<dyn Element> {
|
||||
const RADIUS: f32 = 8.;
|
||||
|
||||
let theme = appearance.theme();
|
||||
let background = if is_selected {
|
||||
Some(internal_colors::accent_overlay_1(theme))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let border_color = if is_selected {
|
||||
theme.accent()
|
||||
} else {
|
||||
Fill::Solid(internal_colors::neutral_4(theme))
|
||||
};
|
||||
|
||||
Hoverable::new(mouse_state, move |_| {
|
||||
let mut container = Container::new(content)
|
||||
.with_uniform_padding(24.)
|
||||
.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(IntentionSlideAction::SelectOption { index });
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_agent_card(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
is_selected: bool,
|
||||
mouse_state: MouseStateHandle,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let bg_solid = theme.background().into_solid();
|
||||
let label_color = if is_selected {
|
||||
internal_colors::text_main(theme, bg_solid)
|
||||
} else {
|
||||
internal_colors::text_sub(theme, bg_solid)
|
||||
};
|
||||
let description_color = internal_colors::text_sub(theme, bg_solid);
|
||||
let checklist_color = label_color;
|
||||
let icon_fill = Fill::Solid(label_color);
|
||||
|
||||
let header_row = {
|
||||
let label = appearance
|
||||
.ui_builder()
|
||||
.paragraph("Build faster with AI agents")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(16.),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
font_color: Some(label_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let mut icon_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
for (i, icon) in [Icon::Oz, Icon::ClaudeLogo, Icon::OpenAILogo]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let el = ConstrainedBox::new(icon.to_warpui_icon(icon_fill).finish())
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
icon_row = if i == 0 {
|
||||
icon_row.with_child(el)
|
||||
} else {
|
||||
icon_row.with_child(Container::new(el).with_margin_left(8.).finish())
|
||||
};
|
||||
}
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(label)
|
||||
.with_child(icon_row.finish())
|
||||
.finish()
|
||||
};
|
||||
|
||||
let description = FormattedTextElement::from_str(
|
||||
"An agent-first experience with best in class terminal support. Get terminal and agent driven development AI features like:",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(description_color)
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Left)
|
||||
.with_line_height_ratio(1.2)
|
||||
.finish();
|
||||
|
||||
let checklist = {
|
||||
let items = AI_FEATURES;
|
||||
// When the agent card is selected, use the theme's green to match the
|
||||
// "Blended ANSI/green_fg" token in the design.
|
||||
let check_fill = if is_selected {
|
||||
Fill::Solid(theme.ansi_fg_green())
|
||||
} else {
|
||||
Fill::Solid(checklist_color)
|
||||
};
|
||||
let mut col = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start);
|
||||
for &item in items {
|
||||
let icon_el = ConstrainedBox::new(Icon::Check.to_warpui_icon(check_fill).finish())
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
let text_el = appearance
|
||||
.ui_builder()
|
||||
.paragraph(item.to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_weight: Some(Weight::Normal),
|
||||
font_color: Some(checklist_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
let row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(icon_el)
|
||||
.with_child(Container::new(text_el).with_margin_left(8.).finish())
|
||||
.finish();
|
||||
col = col.with_child(
|
||||
Container::new(row)
|
||||
.with_padding_top(4.)
|
||||
.with_padding_bottom(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
col.finish()
|
||||
};
|
||||
|
||||
let content = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(header_row)
|
||||
.with_child(Container::new(description).with_margin_top(12.).finish())
|
||||
.with_child(Container::new(checklist).with_margin_top(12.).finish())
|
||||
.finish();
|
||||
|
||||
Self::render_card_chrome(appearance, is_selected, 0, mouse_state, content)
|
||||
}
|
||||
|
||||
fn render_terminal_card(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
is_selected: bool,
|
||||
mouse_state: MouseStateHandle,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let bg_solid = theme.background().into_solid();
|
||||
let text_color = if is_selected {
|
||||
internal_colors::text_main(theme, bg_solid)
|
||||
} else {
|
||||
internal_colors::text_sub(theme, bg_solid)
|
||||
};
|
||||
|
||||
let label = appearance
|
||||
.ui_builder()
|
||||
.paragraph("Just use the terminal")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(16.),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
font_color: Some(text_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let badge = {
|
||||
let badge_text = appearance
|
||||
.ui_builder()
|
||||
.paragraph("No AI features")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
font_color: Some(text_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
Container::new(badge_text)
|
||||
.with_background(internal_colors::fg_overlay_2(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
|
||||
.with_horizontal_padding(4.)
|
||||
.with_vertical_padding(2.)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let header_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(label)
|
||||
.with_child(badge)
|
||||
.finish();
|
||||
|
||||
let description = FormattedTextElement::from_str(
|
||||
"A modern terminal optimized for speed, context, and control without AI.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(text_color)
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Left)
|
||||
.with_line_height_ratio(1.2)
|
||||
.finish();
|
||||
|
||||
let content = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(header_row)
|
||||
.with_child(Container::new(description).with_margin_top(12.).finish())
|
||||
.finish();
|
||||
|
||||
Self::render_card_chrome(appearance, is_selected, 1, mouse_state, content)
|
||||
}
|
||||
|
||||
fn render_bottom_nav(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
selected_index: usize,
|
||||
) -> 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(IntentionSlideAction::BackClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let new_settings_modes = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
|
||||
let next_text = if !new_settings_modes && selected_index == 1 {
|
||||
"Get Warping"
|
||||
} else {
|
||||
"Next"
|
||||
};
|
||||
let enter = Keystroke::parse("enter").unwrap_or_default();
|
||||
let next_button = self.next_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label(next_text.into()),
|
||||
theme: &button::themes::Primary,
|
||||
options: button::Options {
|
||||
keystroke: Some(enter),
|
||||
on_click: Some(Box::new(|ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(IntentionSlideAction::NextClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let is_terminal = selected_index == 1;
|
||||
let (step_index, step_count) = if new_settings_modes {
|
||||
if is_terminal {
|
||||
(0, 4)
|
||||
} else {
|
||||
(0, 5)
|
||||
}
|
||||
} else {
|
||||
(1, 4)
|
||||
};
|
||||
bottom_nav::onboarding_bottom_nav(
|
||||
appearance,
|
||||
step_index,
|
||||
step_count,
|
||||
Some(back_button),
|
||||
Some(next_button),
|
||||
)
|
||||
}
|
||||
|
||||
/// All onboarding image paths used by the intention slide visual.
|
||||
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
|
||||
"async/png/onboarding/welcome_agent.png",
|
||||
"async/png/onboarding/welcome_terminal.png",
|
||||
];
|
||||
|
||||
fn render_visual(&self, appearance: &Appearance, selected_index: usize) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
let path = if selected_index == 1 {
|
||||
Self::VISUAL_IMAGE_PATHS[1]
|
||||
} else {
|
||||
Self::VISUAL_IMAGE_PATHS[0]
|
||||
};
|
||||
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_DEFAULT)
|
||||
} else {
|
||||
let panel_background = internal_colors::neutral_2(theme);
|
||||
let neutral = internal_colors::neutral_4(theme);
|
||||
let neutral_highlight = internal_colors::neutral_6(theme);
|
||||
let accent = internal_colors::accent(theme);
|
||||
|
||||
let visual = if selected_index == 1 {
|
||||
intention_terminal_visual(
|
||||
panel_background,
|
||||
neutral,
|
||||
neutral_highlight,
|
||||
accent.into_solid(),
|
||||
)
|
||||
} else {
|
||||
let blue = theme.ansi_fg_blue();
|
||||
let green = theme.ansi_fg_green();
|
||||
let yellow = theme.ansi_fg_yellow();
|
||||
intention_visual(panel_background, neutral, blue, green, yellow)
|
||||
};
|
||||
|
||||
Container::new(visual)
|
||||
.with_background_color(internal_colors::neutral_1(theme))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for IntentionSlide {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for IntentionSlide {
|
||||
fn ui_name() -> &'static str {
|
||||
"IntentionSlide"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let intention = self.model_intention(app);
|
||||
|
||||
let selected_index = match intention {
|
||||
OnboardingIntention::AgentDrivenDevelopment => 0,
|
||||
OnboardingIntention::Terminal => 1,
|
||||
};
|
||||
|
||||
// Background is rendered by the parent onboarding view (including background images).
|
||||
layout::static_left(
|
||||
|| self.render_content(appearance, selected_index),
|
||||
|| self.render_visual(appearance, selected_index),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntentionSlide {
|
||||
fn select_option(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |model, ctx| match index {
|
||||
0 => model.set_intention_agent_driven_development(ctx),
|
||||
1 => model.set_intention_terminal(ctx),
|
||||
_ => {}
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn next(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
// Always advance to Customize slide; both intentions continue the flow.
|
||||
model.next(ctx);
|
||||
} else {
|
||||
match model.intention() {
|
||||
OnboardingIntention::Terminal => {
|
||||
model.complete(ctx);
|
||||
}
|
||||
OnboardingIntention::AgentDrivenDevelopment => {
|
||||
model.next(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl OnboardingSlide for IntentionSlide {
|
||||
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let selected_index: usize = match self.model_intention(ctx) {
|
||||
OnboardingIntention::AgentDrivenDevelopment => 0,
|
||||
OnboardingIntention::Terminal => 1,
|
||||
};
|
||||
|
||||
self.select_option(selected_index.saturating_sub(1), ctx);
|
||||
}
|
||||
|
||||
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let selected_index: usize = match self.model_intention(ctx) {
|
||||
OnboardingIntention::AgentDrivenDevelopment => 0,
|
||||
OnboardingIntention::Terminal => 1,
|
||||
};
|
||||
|
||||
self.select_option((selected_index + 1).min(1), ctx);
|
||||
}
|
||||
|
||||
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.next(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for IntentionSlide {
|
||||
type Action = IntentionSlideAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
IntentionSlideAction::SelectOption { index } => {
|
||||
self.select_option(*index, ctx);
|
||||
}
|
||||
IntentionSlideAction::BackClicked => {
|
||||
let onboarding_state = self.onboarding_state.clone();
|
||||
onboarding_state.update(ctx, |model, ctx| {
|
||||
model.back(ctx);
|
||||
});
|
||||
}
|
||||
IntentionSlideAction::NextClicked => {
|
||||
self.next(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use crate::model::OnboardingStateModel;
|
||||
use crate::OnboardingEvent;
|
||||
|
||||
use super::OnboardingSlide;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
|
||||
use warpui::{
|
||||
elements::{
|
||||
shimmering_text::{ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle},
|
||||
Align, ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex,
|
||||
FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
|
||||
},
|
||||
keymap::Keystroke,
|
||||
text_layout::TextAlignment,
|
||||
ui_components::components::{UiComponent as _, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum IntroSlideEvent {
|
||||
LoginRequested,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum IntroSlideAction {
|
||||
GetStartedClicked,
|
||||
LoginClicked,
|
||||
}
|
||||
|
||||
pub struct IntroSlide {
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
get_started_button: button::Button,
|
||||
shimmering_title_handle: ShimmeringTextStateHandle,
|
||||
login_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl IntroSlide {
|
||||
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
|
||||
Self {
|
||||
onboarding_state,
|
||||
get_started_button: button::Button::default(),
|
||||
shimmering_title_handle: ShimmeringTextStateHandle::new(),
|
||||
login_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for IntroSlide {
|
||||
type Event = IntroSlideEvent;
|
||||
}
|
||||
|
||||
impl View for IntroSlide {
|
||||
fn ui_name() -> &'static str {
|
||||
"IntroSlide"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let content = self.render_centered_content(appearance);
|
||||
let constrained = ConstrainedBox::new(content).with_max_width(421.).finish();
|
||||
// Background is rendered by the parent onboarding view (including background images).
|
||||
let centered = Container::new(Align::new(constrained).finish()).finish();
|
||||
|
||||
let sub_text_color = internal_colors::text_sub(theme, theme.background().into_solid());
|
||||
let ui_builder = appearance.ui_builder();
|
||||
let disclaimer_styles = UiComponentStyles {
|
||||
font_color: Some(sub_text_color),
|
||||
font_size: Some(12.),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let login_row = Flex::row()
|
||||
.with_child(
|
||||
ui_builder
|
||||
.span("Already have an account? ")
|
||||
.with_style(disclaimer_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
ui_builder
|
||||
.link(
|
||||
"Log in".into(),
|
||||
None,
|
||||
Some(Box::new(|ctx| {
|
||||
ctx.dispatch_typed_action(IntroSlideAction::LoginClicked);
|
||||
})),
|
||||
self.login_mouse_state.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(centered);
|
||||
stack.add_positioned_child(
|
||||
login_row,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -28.),
|
||||
ParentOffsetBounds::ParentBySize,
|
||||
ParentAnchor::BottomMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntroSlide {
|
||||
fn get_started_clicked(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
send_telemetry_from_ctx!(OnboardingEvent::GetStartedClicked, ctx);
|
||||
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.next(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl OnboardingSlide for IntroSlide {
|
||||
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.get_started_clicked(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl IntroSlide {
|
||||
fn render_centered_content(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let logo_fill = internal_colors::fg_overlay_4(theme);
|
||||
let logo = ConstrainedBox::new(Icon::WarpLogoLight.to_warpui_icon(logo_fill).finish())
|
||||
.with_width(64.)
|
||||
.with_height(64.)
|
||||
.finish();
|
||||
|
||||
let base_color: ColorU = internal_colors::fg_overlay_4(theme).into();
|
||||
let shimmer_color: ColorU = theme.foreground().into();
|
||||
let title = ShimmeringTextElement::new(
|
||||
"Welcome to Warp",
|
||||
appearance.ui_font_family(),
|
||||
32.,
|
||||
base_color,
|
||||
shimmer_color,
|
||||
ShimmerConfig::default(),
|
||||
self.shimmering_title_handle.clone(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let subtitle_color = internal_colors::text_sub(theme, theme.background().into_solid());
|
||||
let subtitle = FormattedTextElement::from_str(
|
||||
"A modern terminal with state of the art agents built in.",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(subtitle_color)
|
||||
.with_alignment(TextAlignment::Center)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
let enter = Keystroke::parse("enter").unwrap_or_default();
|
||||
let get_started_button = self.get_started_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label("Get started".into()),
|
||||
theme: &button::themes::Primary,
|
||||
options: button::Options {
|
||||
keystroke: Some(enter),
|
||||
on_click: Some(Box::new(|ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(IntroSlideAction::GetStartedClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(logo)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(subtitle).with_margin_top(12.).finish())
|
||||
.with_child(
|
||||
Container::new(get_started_button)
|
||||
.with_margin_top(24.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for IntroSlide {
|
||||
type Action = IntroSlideAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
IntroSlideAction::GetStartedClicked => {
|
||||
self.get_started_clicked(ctx);
|
||||
}
|
||||
IntroSlideAction::LoginClicked => {
|
||||
send_telemetry_from_ctx!(OnboardingEvent::WelcomeLoginClicked, ctx);
|
||||
ctx.emit(IntroSlideEvent::LoginRequested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use warpui::{
|
||||
assets::asset_cache::AssetSource,
|
||||
elements::{
|
||||
Align, CacheOption, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Empty,
|
||||
Expanded, Flex, Image, MainAxisSize, ParentElement, Point, Shrinkable,
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Stack,
|
||||
},
|
||||
event::DispatchedEvent,
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
|
||||
SizeConstraint,
|
||||
};
|
||||
|
||||
// Onboarding images live under `app/assets/async/` so they are excluded from the WASM
|
||||
// binary (RustEmbed excludes `async/**` on wasm targets). They are still bundled normally
|
||||
// on native builds. Unlike other `async/` assets these are NOT wired up with
|
||||
// `bundled_or_fetched_asset!`, so they cannot be fetched remotely on web. We can't use
|
||||
// that macro here because it resolves paths relative to CARGO_MANIFEST_DIR (i.e.
|
||||
// `crates/onboarding/`), but the assets live under `app/assets/`. Onboarding is not
|
||||
// shown on web, so this is fine.
|
||||
// TODO(APP-3934): support the macro outside the app crate
|
||||
pub const ONBOARDING_BG_PATH: &str = "async/png/onboarding/onboarding_bg.png";
|
||||
|
||||
const LEFT_COLUMN_WIDTH: f32 = 580.;
|
||||
const LEFT_COLUMN_CONTENT_MAX_WIDTH: f32 = 800.;
|
||||
const MIN_RIGHT_COLUMN_WIDTH: f32 = 540.;
|
||||
|
||||
/// The minimum window width at which the two-column layout is shown (left content + right panel).
|
||||
/// Below this width, `static_left` collapses to a single left-only column.
|
||||
pub const TWO_COLUMN_MIN_WIDTH: f32 = LEFT_COLUMN_WIDTH + MIN_RIGHT_COLUMN_WIDTH;
|
||||
|
||||
/// Creates a two-column layout with a fixed-width left column and flexible right column.
|
||||
///
|
||||
/// The left column's *content* is constrained to [`LEFT_COLUMN_CONTENT_MAX_WIDTH`] and is always
|
||||
/// horizontally centered within the left panel.
|
||||
///
|
||||
/// If the available width is too narrow for the right column to have at least
|
||||
/// [`MIN_RIGHT_COLUMN_WIDTH`], we instead render only the left column and center it.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `left` - Builder for the element to display in the left column ([`LEFT_COLUMN_WIDTH`] px)
|
||||
/// * `right` - Builder for the element to display in the right column (flexible width)
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Box<dyn Element>` containing the responsive layout
|
||||
pub fn static_left(
|
||||
left: impl Fn() -> Box<dyn Element>,
|
||||
right: impl FnOnce() -> Box<dyn Element>,
|
||||
) -> Box<dyn Element> {
|
||||
let max_width_for_two_columns = LEFT_COLUMN_WIDTH + MIN_RIGHT_COLUMN_WIDTH;
|
||||
|
||||
let left_constrained = || {
|
||||
ConstrainedBox::new(left())
|
||||
.with_max_width(LEFT_COLUMN_CONTENT_MAX_WIDTH)
|
||||
.finish()
|
||||
};
|
||||
|
||||
// Narrow layout: show only the left section, centered.
|
||||
// Use Align instead of a max-sized Flex so we can safely center even when the incoming
|
||||
// height constraint is unbounded.
|
||||
let left_only = Align::new(left_constrained()).finish();
|
||||
|
||||
// Default layout: fixed-width left + flexible right.
|
||||
// Use Align instead of a max-sized Flex so we can safely center even when the incoming
|
||||
// height constraint is unbounded.
|
||||
let left_centered = Align::new(left_constrained()).finish();
|
||||
|
||||
let left_fixed_width = Container::new(
|
||||
ConstrainedBox::new(left_centered)
|
||||
.with_width(LEFT_COLUMN_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let right_flexible = Shrinkable::new(1., Container::new(right()).finish()).finish();
|
||||
|
||||
let two_column_layout = Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(left_fixed_width)
|
||||
.with_child(right_flexible)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
SizeConstraintSwitch::new(
|
||||
two_column_layout,
|
||||
vec![(
|
||||
SizeConstraintCondition::WidthLessThan(max_width_for_two_columns),
|
||||
left_only,
|
||||
)],
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// How horizontal space around the foreground image is applied.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum HPadding {
|
||||
/// Symmetric fixed padding on both sides (in pixels). Used for the default layout.
|
||||
Fixed(f32),
|
||||
/// Symmetric padding on both sides scales with panel width.
|
||||
/// Value is a fraction of panel width per side (e.g. `39. / 640.`).
|
||||
ProportionalBoth(f32),
|
||||
/// Left padding scales with panel width; image fills the rest to the right edge.
|
||||
/// Value is a fraction of panel width (e.g. `39. / 640.`).
|
||||
ProportionalLeft(f32),
|
||||
/// Right padding scales with panel width; image fills the rest to the left edge.
|
||||
/// `ratio` is a fraction of panel width (e.g. `69. / 640.`).
|
||||
/// `left_offset` is a fixed pixel adjustment to the image's left edge; use a negative
|
||||
/// value to shift left when the asset has built-in visual padding on that side.
|
||||
ProportionalRight { ratio: f32, left_offset: f32 },
|
||||
}
|
||||
|
||||
/// How the vertical top offset for the foreground image is determined.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TopMode {
|
||||
/// Fixed fraction of panel height: `top = panel_height × ratio`.
|
||||
Ratio(f32),
|
||||
/// Dynamic: centers a specific fraction of the image's rendered height in the panel.
|
||||
///
|
||||
/// For `cover()` width-limited: `image_height = panel_width × (1 − 2×h_ratio) × inv_aspect`
|
||||
/// `top = (panel_height × 0.5 − image_height × frac).max(0.)`
|
||||
///
|
||||
/// Adapts to both panel dimensions so the chosen image fraction stays
|
||||
/// centered even when the panel size changes or the image overflows at the bottom.
|
||||
CenterFraction {
|
||||
/// Each side’s proportion of panel width used for horizontal spacing.
|
||||
h_ratio: f32,
|
||||
/// `natural_height / natural_width` of the image asset.
|
||||
inv_aspect: f32,
|
||||
/// The fraction of image height to center in the panel (e.g. `0.25`).
|
||||
frac: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the foreground image is fitted within its slot.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ForegroundFit {
|
||||
/// `cover().top_aligned()`: image fills the slot width; overflows at the bottom.
|
||||
CoverTopAligned,
|
||||
/// `contain().top_aligned()`: full image visible, centered horizontally, pinned to top.
|
||||
ContainTopAligned,
|
||||
/// `contain().right_aligned()`: full image visible, right edge flush, space on the left.
|
||||
ContainRightAligned,
|
||||
}
|
||||
|
||||
/// Sizing and positioning for a foreground image on the onboarding right panel.
|
||||
///
|
||||
/// `top_mode` controls vertical positioning; `h_padding` controls horizontal padding.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ForegroundLayout {
|
||||
/// Vertical positioning mode.
|
||||
pub top_mode: TopMode,
|
||||
/// Horizontal padding mode.
|
||||
pub h_padding: HPadding,
|
||||
/// Image fit and alignment within its slot.
|
||||
pub fit: ForegroundFit,
|
||||
}
|
||||
|
||||
/// Layout for welcome/intention/theme-picker slides.
|
||||
/// Proportional symmetric horizontal padding; cover() fills the slot width.
|
||||
/// Dynamic top: centers the 25%-from-top image point in the panel, adapting to panel size.
|
||||
pub const FOREGROUND_LAYOUT_DEFAULT: ForegroundLayout = ForegroundLayout {
|
||||
top_mode: TopMode::CenterFraction {
|
||||
h_ratio: 39. / 640.,
|
||||
inv_aspect: 612. / 561.,
|
||||
frac: 0.25,
|
||||
},
|
||||
h_padding: HPadding::ProportionalBoth(39. / 640.),
|
||||
fit: ForegroundFit::CoverTopAligned,
|
||||
};
|
||||
|
||||
/// Layout for customize slides.
|
||||
/// Proportional left padding; image fills to the right edge via cover.
|
||||
/// We cover because the most important part is the top left of the image.
|
||||
pub const FOREGROUND_LAYOUT_WIDE: ForegroundLayout = ForegroundLayout {
|
||||
top_mode: TopMode::Ratio(118. / 800.),
|
||||
h_padding: HPadding::ProportionalLeft(39. / 640.),
|
||||
fit: ForegroundFit::CoverTopAligned,
|
||||
};
|
||||
|
||||
/// Layout for code-review customize images.
|
||||
/// Proportional right padding; portrait image fills to the left edge and overflows bottom.
|
||||
/// We cover because the most important part is the top right of the image.
|
||||
pub const FOREGROUND_LAYOUT_CODE_REVIEW: ForegroundLayout = ForegroundLayout {
|
||||
top_mode: TopMode::Ratio(118. / 800.),
|
||||
h_padding: HPadding::ProportionalRight {
|
||||
ratio: 69. / 640.,
|
||||
left_offset: -16.,
|
||||
},
|
||||
fit: ForegroundFit::CoverTopAligned,
|
||||
};
|
||||
|
||||
/// Layout for third-party slides.
|
||||
/// No horizontal padding; contain() shows the full image; right-aligned so any
|
||||
/// leftover horizontal space appears on the left.
|
||||
/// We contain because the most important part is the bottom of the image which needs to be visible.
|
||||
pub const FOREGROUND_LAYOUT_THIRD_PARTY: ForegroundLayout = ForegroundLayout {
|
||||
top_mode: TopMode::Ratio(118. / 800.),
|
||||
h_padding: HPadding::Fixed(0.),
|
||||
fit: ForegroundFit::ContainRightAligned,
|
||||
};
|
||||
|
||||
/// Wraps an image slot with a dynamic top offset that keeps a chosen fraction
|
||||
/// of the image height centered in the panel.
|
||||
///
|
||||
/// The image slot is given the remaining panel height after the computed top offset.
|
||||
/// The slot's paint overflows downward (visible up to the panel `Clipped` boundary).
|
||||
struct CenterFractionTopWrapper {
|
||||
h_ratio: f32,
|
||||
inv_aspect: f32,
|
||||
frac: f32,
|
||||
inner: Box<dyn Element>,
|
||||
panel_size: Option<Vector2F>,
|
||||
top: f32,
|
||||
origin: Option<Point>,
|
||||
}
|
||||
|
||||
impl CenterFractionTopWrapper {
|
||||
fn new(h_ratio: f32, inv_aspect: f32, frac: f32, inner: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
h_ratio,
|
||||
inv_aspect,
|
||||
frac,
|
||||
inner,
|
||||
panel_size: None,
|
||||
top: 0.,
|
||||
origin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for CenterFractionTopWrapper {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let panel = constraint.max;
|
||||
self.panel_size = Some(panel);
|
||||
// image_height for cover() width-limited: slot_width × inv_aspect
|
||||
let slot_w = panel.x() * (1. - 2. * self.h_ratio);
|
||||
let image_h = slot_w * self.inv_aspect;
|
||||
self.top = (panel.y() * 0.5 - image_h * self.frac).max(0.);
|
||||
let slot_h = (panel.y() - self.top).max(1.);
|
||||
let slot = vec2f(panel.x(), slot_h);
|
||||
self.inner.layout(SizeConstraint::new(slot, slot), ctx, app);
|
||||
panel
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.inner.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
self.inner.paint(origin + vec2f(0., self.top), ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_event: &DispatchedEvent,
|
||||
_ctx: &mut EventContext,
|
||||
_app: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.panel_size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a slide's foreground visual with the shared onboarding background image.
|
||||
///
|
||||
/// For `TopMode::Ratio`: uses a `Flex::column` with a proportional `Expanded` top spacer.
|
||||
/// For `TopMode::CenterFraction`: uses `CenterFractionTopWrapper` which computes the
|
||||
/// top offset dynamically at layout time based on the actual panel dimensions.
|
||||
pub fn onboarding_right_panel_with_bg(
|
||||
path: &'static str,
|
||||
layout: ForegroundLayout,
|
||||
) -> Box<dyn Element> {
|
||||
let background = Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: ONBOARDING_BG_PATH,
|
||||
},
|
||||
CacheOption::Original,
|
||||
)
|
||||
.stretch()
|
||||
.finish();
|
||||
|
||||
let image = match layout.fit {
|
||||
ForegroundFit::CoverTopAligned => {
|
||||
Image::new(AssetSource::Bundled { path }, CacheOption::Original)
|
||||
.cover()
|
||||
.top_aligned()
|
||||
.finish()
|
||||
}
|
||||
ForegroundFit::ContainTopAligned => {
|
||||
Image::new(AssetSource::Bundled { path }, CacheOption::Original)
|
||||
.contain()
|
||||
.top_aligned()
|
||||
.finish()
|
||||
}
|
||||
ForegroundFit::ContainRightAligned => {
|
||||
Image::new(AssetSource::Bundled { path }, CacheOption::Original)
|
||||
.contain()
|
||||
.right_aligned()
|
||||
.finish()
|
||||
}
|
||||
};
|
||||
|
||||
// Apply horizontal padding: fixed pixels (DEFAULT) or proportional to panel width.
|
||||
let image_slot: Box<dyn Element> = match layout.h_padding {
|
||||
HPadding::Fixed(px) => Container::new(image)
|
||||
.with_padding_left(px)
|
||||
.with_padding_right(px)
|
||||
.finish(),
|
||||
HPadding::ProportionalBoth(ratio) => Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
|
||||
.with_child(Box::new(Expanded::new(1. - 2. * ratio, image)))
|
||||
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
|
||||
.finish(),
|
||||
HPadding::ProportionalLeft(ratio) => Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
|
||||
.with_child(Box::new(Expanded::new(1. - ratio, image)))
|
||||
.finish(),
|
||||
HPadding::ProportionalRight { ratio, left_offset } => {
|
||||
let adjusted = if left_offset != 0. {
|
||||
Container::new(image)
|
||||
.with_padding_left(left_offset)
|
||||
.finish()
|
||||
} else {
|
||||
image
|
||||
};
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(Box::new(Expanded::new(1. - ratio, adjusted)))
|
||||
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
|
||||
.finish()
|
||||
}
|
||||
};
|
||||
|
||||
// Build the foreground with either a fixed or dynamic top offset.
|
||||
let foreground: Box<dyn Element> = match layout.top_mode {
|
||||
TopMode::Ratio(ratio) => {
|
||||
let remaining = 1. - ratio;
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
|
||||
.with_child(Box::new(Expanded::new(remaining, image_slot)))
|
||||
.finish()
|
||||
}
|
||||
TopMode::CenterFraction {
|
||||
h_ratio,
|
||||
inv_aspect,
|
||||
frac,
|
||||
} => Box::new(CenterFractionTopWrapper::new(
|
||||
h_ratio, inv_aspect, frac, image_slot,
|
||||
)),
|
||||
};
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.extend(Some(background));
|
||||
stack.extend(Some(foreground));
|
||||
|
||||
Clipped::new(stack.finish()).finish()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
mod agent_slide;
|
||||
mod bottom_nav;
|
||||
mod customize_slide;
|
||||
mod free_user_no_ai_slide;
|
||||
mod intention_slide;
|
||||
mod intro_slide;
|
||||
pub mod layout;
|
||||
mod onboarding_slide;
|
||||
mod progress_dots;
|
||||
mod project_slide;
|
||||
pub mod slide_content;
|
||||
mod theme_picker_slide;
|
||||
mod third_party_slide;
|
||||
mod toggle_card;
|
||||
mod two_line_button;
|
||||
|
||||
pub use agent_slide::{
|
||||
AgentAutonomy, AgentDevelopmentSettings, AgentSlide, AgentSlideEvent, OnboardingModelInfo,
|
||||
};
|
||||
pub use bottom_nav::onboarding_bottom_nav;
|
||||
pub use customize_slide::CustomizeUISlide;
|
||||
pub use free_user_no_ai_slide::FreeUserNoAiSlide;
|
||||
pub use intention_slide::IntentionSlide;
|
||||
pub use intro_slide::{IntroSlide, IntroSlideEvent};
|
||||
pub use onboarding_slide::OnboardingSlide;
|
||||
pub use project_slide::{ProjectOnboardingSettings, ProjectSlide};
|
||||
pub use theme_picker_slide::{ThemePickerSlide, ThemePickerSlideEvent};
|
||||
pub use third_party_slide::ThirdPartySlide;
|
||||
@@ -0,0 +1,12 @@
|
||||
use warpui::{View, ViewContext};
|
||||
|
||||
pub trait OnboardingSlide: View {
|
||||
fn on_up(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
fn on_down(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
fn on_left(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
fn on_right(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
fn on_tab(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
fn on_enter(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
fn on_cmd_or_ctrl_enter(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
fn on_escape(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CornerRadius, Empty, Flex, MainAxisSize, ParentElement, Radius,
|
||||
},
|
||||
Element,
|
||||
};
|
||||
|
||||
/// Render `n` dots with 4px radius and 8px spacing. `k` is the 0-based active dot index.
|
||||
pub(crate) fn progress_dots(n: usize, k: usize, appearance: &Appearance) -> Box<dyn Element> {
|
||||
const DOT_RADIUS: f32 = 4.;
|
||||
const DOT_DIAMETER: f32 = DOT_RADIUS * 2.;
|
||||
const DOT_SPACING: f32 = 8.;
|
||||
|
||||
let theme = appearance.theme();
|
||||
let neutral = internal_colors::neutral_4(theme);
|
||||
let accent = internal_colors::accent(theme).into_solid();
|
||||
|
||||
let dots = (0..n)
|
||||
.map(|i| {
|
||||
let color = if i == k { accent } else { neutral };
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background_color(color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(DOT_RADIUS)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(DOT_DIAMETER)
|
||||
.with_height(DOT_DIAMETER)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(if i == 0 { 0. } else { DOT_SPACING })
|
||||
.finish()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_children(dots)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
use crate::model::OnboardingStateModel;
|
||||
use crate::slides::{bottom_nav, layout, slide_content};
|
||||
use crate::telemetry::OnboardingEvent;
|
||||
use crate::visuals::project_visual;
|
||||
use ui_components::{button, keyboard_shortcut, Component as _, Options as _};
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warp_core::ui::{
|
||||
appearance::Appearance, color::coloru_with_opacity, theme::color::internal_colors, Icon,
|
||||
};
|
||||
use warpui::prelude::{MainAxisAlignment, MainAxisSize, Vector2F};
|
||||
use warpui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ClippedScrollStateHandle, ConstrainedBox, Container, CrossAxisAlignment, Flex,
|
||||
MouseStateHandle, ParentElement, Shrinkable,
|
||||
},
|
||||
fonts::Weight,
|
||||
keymap::Keystroke,
|
||||
platform::file_picker::{FilePickerConfiguration, FilePickerError},
|
||||
ui_components::components::{UiComponent as _, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
use super::OnboardingSlide;
|
||||
|
||||
const LEFT_COLUMN_W: f32 = 428.;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ProjectOnboardingSettings {
|
||||
#[default]
|
||||
NoProject,
|
||||
Project {
|
||||
selected_local_folder: String,
|
||||
initialize_projects_automatically: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProjectOnboardingSettings {
|
||||
pub fn from_path(path: Option<String>) -> Self {
|
||||
match path {
|
||||
None => ProjectOnboardingSettings::NoProject,
|
||||
Some(path) => ProjectOnboardingSettings::Project {
|
||||
selected_local_folder: path,
|
||||
initialize_projects_automatically: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProjectSlideAction {
|
||||
BackClicked,
|
||||
NextClicked,
|
||||
SkipClicked,
|
||||
OpenLocalFolderClicked,
|
||||
LocalFolderSelected(Result<String, FilePickerError>),
|
||||
ToggleInitializeProjectsAutomatically,
|
||||
}
|
||||
|
||||
pub struct ProjectSlide {
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
open_folder_mouse_state: MouseStateHandle,
|
||||
back_button: button::Button,
|
||||
next_button: button::Button,
|
||||
initialize_projects_automatically_mouse_state: MouseStateHandle,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl ProjectSlide {
|
||||
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
|
||||
Self {
|
||||
onboarding_state,
|
||||
open_folder_mouse_state: MouseStateHandle::default(),
|
||||
back_button: button::Button::default(),
|
||||
next_button: button::Button::default(),
|
||||
initialize_projects_automatically_mouse_state: MouseStateHandle::default(),
|
||||
scroll_state: ClippedScrollStateHandle::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_settings<'a>(&self, app: &'a AppContext) -> &'a ProjectOnboardingSettings {
|
||||
self.onboarding_state.as_ref(app).project_settings()
|
||||
}
|
||||
|
||||
fn render_content(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
settings: &ProjectOnboardingSettings,
|
||||
agent_modality_enabled: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let mut children = vec![
|
||||
Align::new(self.render_header(appearance)).finish(),
|
||||
Align::new(self.render_open_folder_button(appearance, settings)).finish(),
|
||||
];
|
||||
|
||||
// Only show the "Initialize project automatically" checkbox when AgentView is NOT enabled.
|
||||
// When AgentView is enabled, initialization is handled differently through the callout flow.
|
||||
if !agent_modality_enabled {
|
||||
if let ProjectOnboardingSettings::Project {
|
||||
initialize_projects_automatically,
|
||||
..
|
||||
} = settings
|
||||
{
|
||||
children.push(
|
||||
Align::new(
|
||||
self.render_project_options(*initialize_projects_automatically, appearance),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let bottom_nav = Align::new(self.render_bottom_nav(appearance, settings)).finish();
|
||||
slide_content::onboarding_slide_content(
|
||||
children,
|
||||
bottom_nav,
|
||||
self.scroll_state.clone(),
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let title = appearance
|
||||
.ui_builder()
|
||||
.paragraph("Open a project")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(36.),
|
||||
font_weight: Some(Weight::Medium),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let subtitle = appearance
|
||||
.ui_builder()
|
||||
.paragraph("Set up a project to optimize it for coding in Warp.")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(20.),
|
||||
font_weight: Some(Weight::Normal),
|
||||
font_color: Some(internal_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(subtitle).with_margin_top(8.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_max_width(LEFT_COLUMN_W)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_open_folder_button(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
settings: &ProjectOnboardingSettings,
|
||||
) -> Box<dyn Element> {
|
||||
// Match the intention/agent layout: a wide button within the left column.
|
||||
|
||||
let theme = appearance.theme();
|
||||
let (label, variant) = match settings {
|
||||
ProjectOnboardingSettings::Project {
|
||||
selected_local_folder,
|
||||
..
|
||||
} => (
|
||||
TextAndIcon::new(
|
||||
TextAndIconAlignment::IconFirst,
|
||||
selected_local_folder.to_owned(),
|
||||
Icon::Folder.to_warpui_icon(theme.foreground()),
|
||||
MainAxisSize::Max,
|
||||
MainAxisAlignment::Center,
|
||||
Vector2F::new(16., 16.),
|
||||
)
|
||||
.with_inner_padding(8.),
|
||||
ButtonVariant::Secondary,
|
||||
),
|
||||
ProjectOnboardingSettings::NoProject => {
|
||||
let enter = Keystroke::parse("enter").unwrap_or_default();
|
||||
let text_color = theme.foreground().into();
|
||||
let border_color = coloru_with_opacity(text_color, 60);
|
||||
|
||||
let shortcut = keyboard_shortcut::KeyboardShortcut.render(
|
||||
appearance,
|
||||
keyboard_shortcut::Params {
|
||||
keystroke: enter,
|
||||
options: keyboard_shortcut::Options {
|
||||
font_color: Some(text_color),
|
||||
background: None,
|
||||
border_fill: Some(border_color.into()),
|
||||
sizing: keyboard_shortcut::Sizing {
|
||||
font_size: 12.,
|
||||
padding: 2.,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let folder_icon =
|
||||
ConstrainedBox::new(Icon::Folder.to_warpui_icon(theme.foreground()).finish())
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
let folder_text = Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.paragraph("Open local folder")
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(text_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish();
|
||||
|
||||
let enter_shortcut = Container::new(shortcut).with_margin_left(8.).finish();
|
||||
|
||||
let label = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([folder_icon, folder_text, enter_shortcut])
|
||||
.finish();
|
||||
|
||||
let button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.open_folder_mouse_state.clone())
|
||||
.with_custom_label(label)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ProjectSlideAction::OpenLocalFolderClicked)
|
||||
})
|
||||
.finish();
|
||||
|
||||
return Container::new(
|
||||
ConstrainedBox::new(button)
|
||||
.with_width(LEFT_COLUMN_W)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(24.)
|
||||
.finish();
|
||||
}
|
||||
};
|
||||
|
||||
let button = appearance
|
||||
.ui_builder()
|
||||
.button(variant, self.open_folder_mouse_state.clone())
|
||||
.with_text_and_icon_label(label)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ProjectSlideAction::OpenLocalFolderClicked)
|
||||
})
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(button)
|
||||
.with_width(LEFT_COLUMN_W)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_bottom_nav(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
settings: &ProjectOnboardingSettings,
|
||||
) -> 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(ProjectSlideAction::BackClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let theme_picker_last =
|
||||
warp_core::features::FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
|
||||
|
||||
let (label, keystroke, action) = match settings {
|
||||
ProjectOnboardingSettings::Project { .. } => (
|
||||
if theme_picker_last {
|
||||
"Next"
|
||||
} else {
|
||||
"Get Warping"
|
||||
},
|
||||
Keystroke::parse("enter").unwrap_or_default(),
|
||||
ProjectSlideAction::NextClicked,
|
||||
),
|
||||
ProjectOnboardingSettings::NoProject => (
|
||||
"Skip",
|
||||
Keystroke::parse("cmdorctrl-enter").unwrap_or_default(),
|
||||
ProjectSlideAction::SkipClicked,
|
||||
),
|
||||
};
|
||||
|
||||
let next_button = self.next_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label(label.into()),
|
||||
theme: &button::themes::Primary,
|
||||
options: button::Options {
|
||||
keystroke: Some(keystroke),
|
||||
on_click: Some(Box::new(move |ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(action.clone());
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// The project slide is unreachable in the new flow (ThirdParty → ThemePicker),
|
||||
// so only the legacy step counts apply.
|
||||
let (step_index, step_count) = (3, 4);
|
||||
bottom_nav::onboarding_bottom_nav(
|
||||
appearance,
|
||||
step_index,
|
||||
step_count,
|
||||
Some(back_button),
|
||||
Some(next_button),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_option(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
mouse_state: MouseStateHandle,
|
||||
checked: bool,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
action: ProjectSlideAction,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let action = action.clone();
|
||||
|
||||
let checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(mouse_state, Some(12.))
|
||||
.check(checked)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
|
||||
.finish();
|
||||
|
||||
let title = appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(title, true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
font_weight: Some(Weight::Normal),
|
||||
font_color: Some(theme.sub_text_color(theme.background()).into_solid()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let description = appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(description, true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
font_weight: Some(Weight::Normal),
|
||||
font_color: Some(theme.disabled_text_color(theme.background()).into_solid()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let text_col = Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(4.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(3.0)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(checkbox)
|
||||
.with_child(Shrinkable::new(1., text_col).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_project_options(
|
||||
&self,
|
||||
initialize_projects_automatically: bool,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let initialize = self.render_option(
|
||||
appearance,
|
||||
self.initialize_projects_automatically_mouse_state.clone(),
|
||||
initialize_projects_automatically,
|
||||
"Initialize project automatically",
|
||||
"Prepares the project environment, builds an index of your code, and generates project rules—giving the agent deeper understanding and better performance.",
|
||||
ProjectSlideAction::ToggleInitializeProjectsAutomatically,
|
||||
);
|
||||
|
||||
// Keep this aligned with the folder button width.
|
||||
ConstrainedBox::new(Container::new(initialize).with_margin_top(16.).finish())
|
||||
.with_width(LEFT_COLUMN_W)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_visual(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let panel_background = internal_colors::neutral_2(theme);
|
||||
|
||||
let pill_color = internal_colors::fg_overlay_1(theme).into_solid();
|
||||
let center_icon_color = internal_colors::neutral_5(theme);
|
||||
let side_icon_color = internal_colors::neutral_4(theme);
|
||||
|
||||
Container::new(project_visual(
|
||||
panel_background,
|
||||
pill_color,
|
||||
center_icon_color,
|
||||
side_icon_color,
|
||||
))
|
||||
.with_background_color(internal_colors::neutral_1(theme))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ProjectSlide {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for ProjectSlide {
|
||||
fn ui_name() -> &'static str {
|
||||
"ProjectSlide"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let settings = self.project_settings(app);
|
||||
let agent_modality_enabled = self.onboarding_state.as_ref(app).agent_modality_enabled();
|
||||
|
||||
layout::static_left(
|
||||
|| self.render_content(appearance, settings, agent_modality_enabled),
|
||||
|| self.render_visual(appearance),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectSlide {
|
||||
fn open_local_folder(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
send_telemetry_from_ctx!(OnboardingEvent::FolderSelectionStarted, ctx);
|
||||
ctx.open_file_picker(
|
||||
|result, ctx| {
|
||||
if let Some(path_result) = result.map(|paths| paths.into_iter().next()).transpose()
|
||||
{
|
||||
ctx.dispatch_typed_action(&ProjectSlideAction::LocalFolderSelected(
|
||||
path_result,
|
||||
));
|
||||
}
|
||||
},
|
||||
FilePickerConfiguration::new().folders_only(),
|
||||
);
|
||||
}
|
||||
|
||||
fn next(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !matches!(
|
||||
self.project_settings(ctx),
|
||||
ProjectOnboardingSettings::Project { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
if warp_core::features::FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
model.next(ctx);
|
||||
} else {
|
||||
model.complete(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn skip(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_project_selected_local_folder(None, ctx);
|
||||
if warp_core::features::FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
model.next(ctx);
|
||||
} else {
|
||||
model.complete(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl OnboardingSlide for ProjectSlide {
|
||||
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
match self.project_settings(ctx) {
|
||||
ProjectOnboardingSettings::NoProject => self.open_local_folder(ctx),
|
||||
ProjectOnboardingSettings::Project { .. } => self.next(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_cmd_or_ctrl_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if matches!(
|
||||
self.project_settings(ctx),
|
||||
ProjectOnboardingSettings::NoProject
|
||||
) {
|
||||
self.skip(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ProjectSlide {
|
||||
type Action = ProjectSlideAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ProjectSlideAction::BackClicked => {
|
||||
let onboarding_state = self.onboarding_state.clone();
|
||||
onboarding_state.update(ctx, |model, ctx| {
|
||||
model.back(ctx);
|
||||
});
|
||||
}
|
||||
ProjectSlideAction::NextClicked => {
|
||||
self.next(ctx);
|
||||
}
|
||||
ProjectSlideAction::SkipClicked => {
|
||||
self.skip(ctx);
|
||||
}
|
||||
ProjectSlideAction::OpenLocalFolderClicked => {
|
||||
self.open_local_folder(ctx);
|
||||
}
|
||||
ProjectSlideAction::LocalFolderSelected(result) => match result {
|
||||
Ok(path) => {
|
||||
let onboarding_state = self.onboarding_state.clone();
|
||||
onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_project_selected_local_folder(Some(path.clone()), ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("File picker error during onboarding: {err}");
|
||||
}
|
||||
},
|
||||
ProjectSlideAction::ToggleInitializeProjectsAutomatically => {
|
||||
let onboarding_state = self.onboarding_state.clone();
|
||||
onboarding_state.update(ctx, |model, ctx| {
|
||||
model.toggle_project_initialize_projects_automatically(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ClippedScrollStateHandle, ClippedScrollable, Container, CrossAxisAlignment, Flex,
|
||||
MainAxisSize, ParentElement, ScrollbarWidth, Shrinkable,
|
||||
},
|
||||
Element,
|
||||
};
|
||||
|
||||
pub fn onboarding_slide_content(
|
||||
children: Vec<Box<dyn Element>>,
|
||||
bottom_nav: Box<dyn Element>,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
const PADDING: f32 = 64.;
|
||||
|
||||
// Build the content column with its natural (minimum) height.
|
||||
let mut content_column = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
for child in children {
|
||||
content_column = content_column.with_child(child);
|
||||
}
|
||||
|
||||
// Apply right padding inside the scrollable so the scrollbar sits at the
|
||||
// outer edge of the slide rather than overlapping the content.
|
||||
let padded_content = Container::new(content_column.finish())
|
||||
.with_padding_right(PADDING)
|
||||
.finish();
|
||||
|
||||
// Wrap the content in Align so it is centered within the visible area
|
||||
// when there is enough space.
|
||||
let centered_content = Align::new(padded_content).finish();
|
||||
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Create a scrollable content area using vertical_centered so the child's
|
||||
// min-height matches the visible height (enabling Align to center).
|
||||
let scrollable = ClippedScrollable::vertical_centered(
|
||||
scroll_state,
|
||||
centered_content,
|
||||
ScrollbarWidth::Auto,
|
||||
theme.disabled_text_color(theme.background()).into(),
|
||||
theme.main_text_color(theme.background()).into(),
|
||||
theme.background().into(),
|
||||
)
|
||||
.with_overlayed_scrollbar()
|
||||
.finish();
|
||||
|
||||
// Outer layout: scrollable content takes remaining space, bottom nav
|
||||
// is always visible at the bottom.
|
||||
let outer = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(Shrinkable::new(1., scrollable).finish())
|
||||
.with_child(
|
||||
Container::new(bottom_nav)
|
||||
.with_margin_top(24.)
|
||||
.with_padding_right(PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Left/top/bottom padding on the outer container; right padding is handled
|
||||
// inside the scrollable and bottom nav so the scrollbar stays at the edge.
|
||||
Container::new(outer)
|
||||
.with_padding_top(PADDING)
|
||||
.with_padding_bottom(PADDING)
|
||||
.with_padding_left(PADDING)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
use super::OnboardingSlide;
|
||||
use crate::model::{OnboardingStateEvent, OnboardingStateModel};
|
||||
use crate::slides::{bottom_nav, layout, slide_content};
|
||||
use crate::telemetry::OnboardingEvent;
|
||||
use crate::visuals::theme_picker_visual;
|
||||
use crate::OnboardingIntention;
|
||||
use pathfinder_color::ColorU;
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, theme::WarpTheme};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, Empty, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
keymap::Keystroke,
|
||||
platform::Cursor,
|
||||
text_layout::TextAlignment,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ThemePickerSlideEvent {
|
||||
ThemeSelected {
|
||||
theme_name: String,
|
||||
},
|
||||
SyncWithOsToggled {
|
||||
enabled: bool,
|
||||
},
|
||||
/// Emitted when the user clicks the "Privacy Settings" link on the terminal
|
||||
/// intention theme slide. The parent orchestrator is expected to open the
|
||||
/// privacy settings (e.g. via a LoginSlideView in privacy-only mode).
|
||||
PrivacySettingsRequested,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ThemePickerSlideAction {
|
||||
SelectTheme {
|
||||
index: usize,
|
||||
},
|
||||
ToggleSyncWithOs,
|
||||
BackClicked,
|
||||
NextClicked,
|
||||
/// Dispatched when the user clicks the "Privacy Settings" link in the
|
||||
/// terminal-intention disclaimer block below the theme options.
|
||||
PrivacySettingsClicked,
|
||||
}
|
||||
|
||||
const TOS_URL: &str = "https://www.warp.dev/terms-of-service";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ThemeOption {
|
||||
theme: WarpTheme,
|
||||
mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct ThemePickerSlide {
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
theme_options: [ThemeOption; 4],
|
||||
selected_theme_index: usize,
|
||||
sync_with_os: bool,
|
||||
sync_with_os_mouse: MouseStateHandle,
|
||||
tos_mouse_state: MouseStateHandle,
|
||||
privacy_settings_mouse_state: MouseStateHandle,
|
||||
back_button: button::Button,
|
||||
next_button: button::Button,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl ThemePickerSlide {
|
||||
pub(crate) fn new(
|
||||
themes: [WarpTheme; 4],
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let theme_options = themes.map(|theme| ThemeOption {
|
||||
theme,
|
||||
mouse_state: MouseStateHandle::default(),
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&onboarding_state, |_me, _model, event, ctx| {
|
||||
if matches!(event, OnboardingStateEvent::IntentionChanged) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let current_theme_name = appearance.theme().name();
|
||||
|
||||
let selected_theme_index = current_theme_name
|
||||
.as_ref()
|
||||
.and_then(|name| {
|
||||
theme_options
|
||||
.iter()
|
||||
.position(|option| option.theme.name().as_ref() == Some(name))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
// If the current appearance theme isn't one of the provided choices, reset it to
|
||||
// the first option so our selection and the rendered theme match.
|
||||
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
||||
appearance.set_theme(theme_options[0].theme.clone(), ctx);
|
||||
});
|
||||
0
|
||||
});
|
||||
|
||||
Self {
|
||||
onboarding_state,
|
||||
theme_options,
|
||||
selected_theme_index,
|
||||
sync_with_os: false,
|
||||
sync_with_os_mouse: MouseStateHandle::default(),
|
||||
tos_mouse_state: MouseStateHandle::default(),
|
||||
privacy_settings_mouse_state: MouseStateHandle::default(),
|
||||
back_button: button::Button::default(),
|
||||
next_button: button::Button::default(),
|
||||
scroll_state: ClippedScrollStateHandle::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_display_name(&self, index: usize) -> String {
|
||||
self.theme_options
|
||||
.get(index)
|
||||
.and_then(|option| option.theme.name())
|
||||
.unwrap_or_else(|| format!("Theme {}", index + 1))
|
||||
}
|
||||
|
||||
fn render_theme_picker_content(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
// The option "chrome" (background, borders, text) should be styled using the currently
|
||||
// selected theme.
|
||||
let selected_theme = self
|
||||
.theme_options
|
||||
.get(self.selected_theme_index)
|
||||
.map(|option| option.theme.clone())
|
||||
.unwrap_or_else(|| self.theme_options[0].theme.clone());
|
||||
|
||||
let bottom_nav = self.render_bottom_nav(appearance, app);
|
||||
|
||||
let theme_options = self.render_theme_options(appearance, &selected_theme);
|
||||
|
||||
// Apply a semi-transparent overlay to visually disable the theme options
|
||||
// when the "Sync with OS" checkbox is checked.
|
||||
let theme_options_section: Box<dyn Element> = if self.sync_with_os {
|
||||
let bg = appearance.theme().background().into_solid();
|
||||
let overlay_color = ColorU::new(bg.r, bg.g, bg.b, 128);
|
||||
Container::new(theme_options)
|
||||
.with_foreground_overlay(overlay_color)
|
||||
.finish()
|
||||
} else {
|
||||
theme_options
|
||||
};
|
||||
|
||||
let mut content = vec![self.render_header_text(appearance), theme_options_section];
|
||||
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
content.push(self.render_sync_with_os_section(appearance));
|
||||
}
|
||||
|
||||
// Add the Privacy Settings / Terms of Service disclaimer block below the
|
||||
// theme options when the user has selected the terminal intention and
|
||||
// won't hit the login slide afterwards. The terminal-intent flow skips
|
||||
// the login slide (which surfaces the same links) unless Warp Drive is
|
||||
// enabled — in that case the login slide will still run after the theme
|
||||
// step and show the disclaimer, so duplicating it here is unnecessary.
|
||||
let state = self.onboarding_state.as_ref(app);
|
||||
let is_terminal = matches!(state.intention(), OnboardingIntention::Terminal);
|
||||
let warp_drive_enabled = state.ui_customization().show_warp_drive;
|
||||
if is_terminal && !warp_drive_enabled && FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
|
||||
{
|
||||
content.push(self.render_disclaimer_section(appearance));
|
||||
}
|
||||
|
||||
slide_content::onboarding_slide_content(
|
||||
content,
|
||||
bottom_nav,
|
||||
self.scroll_state.clone(),
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_header_text(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let title = appearance
|
||||
.ui_builder()
|
||||
.paragraph("Choose a theme")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(36.),
|
||||
font_weight: Some(Weight::Medium),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let subtitle = FormattedTextElement::from_str(
|
||||
"Click or use arrow keys to select, Enter to confirm.",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(internal_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background().into_solid(),
|
||||
))
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Left)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(subtitle).with_margin_top(16.).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_theme_options(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
chrome_theme: &WarpTheme,
|
||||
) -> Box<dyn Element> {
|
||||
let options = (0..self.theme_options.len())
|
||||
.map(|index| {
|
||||
let theme_name = self.theme_display_name(index);
|
||||
let option = &self.theme_options[index];
|
||||
|
||||
self.render_theme_option(
|
||||
appearance,
|
||||
chrome_theme,
|
||||
index,
|
||||
theme_name,
|
||||
&option.theme,
|
||||
option.mouse_state.clone(),
|
||||
!self.sync_with_os,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_children(options)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(40.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_bottom_nav(&self, appearance: &Appearance, app: &AppContext) -> 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(ThemePickerSlideAction::BackClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let theme_picker_last = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
|
||||
let next_label = if theme_picker_last {
|
||||
"Get Warping"
|
||||
} else {
|
||||
"Next"
|
||||
};
|
||||
|
||||
let enter = Keystroke::parse("enter").unwrap_or_default();
|
||||
let next_button = self.next_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label(next_label.into()),
|
||||
theme: &button::themes::Primary,
|
||||
options: button::Options {
|
||||
keystroke: Some(enter),
|
||||
on_click: Some(Box::new(|ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(ThemePickerSlideAction::NextClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let (step_index, step_count) = if theme_picker_last {
|
||||
let is_terminal = matches!(
|
||||
self.onboarding_state.as_ref(app).intention(),
|
||||
OnboardingIntention::Terminal
|
||||
);
|
||||
if is_terminal {
|
||||
(3, 4)
|
||||
} else {
|
||||
(4, 5)
|
||||
}
|
||||
} else {
|
||||
(0, 4)
|
||||
};
|
||||
|
||||
bottom_nav::onboarding_bottom_nav(
|
||||
appearance,
|
||||
step_index,
|
||||
step_count,
|
||||
Some(back_button),
|
||||
Some(next_button),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_theme_option(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
chrome_theme: &WarpTheme,
|
||||
index: usize,
|
||||
theme_name: String,
|
||||
option_theme: &WarpTheme,
|
||||
mouse_state: MouseStateHandle,
|
||||
interactive: bool,
|
||||
) -> Box<dyn Element> {
|
||||
const SWATCH_RADIUS_PX: f32 = 11.0;
|
||||
const SWATCH_DIAMETER_PX: f32 = SWATCH_RADIUS_PX * 2.0;
|
||||
|
||||
let theme_name_owned = theme_name;
|
||||
let is_selected = !self.sync_with_os && self.selected_theme_index == index;
|
||||
|
||||
let background = if is_selected {
|
||||
internal_colors::accent_bg(chrome_theme)
|
||||
} else {
|
||||
chrome_theme.surface_2()
|
||||
};
|
||||
|
||||
let border_color = if is_selected {
|
||||
chrome_theme.accent()
|
||||
} else {
|
||||
chrome_theme.surface_overlay_1()
|
||||
};
|
||||
|
||||
// Choose text color based on the actual background fill.
|
||||
let text_color = chrome_theme.main_text_color(background);
|
||||
|
||||
// Only the swatches use the *option* theme.
|
||||
let swatch_colors = [
|
||||
option_theme.ansi_fg_red(),
|
||||
option_theme.ansi_fg_green(),
|
||||
option_theme.ansi_fg_blue(),
|
||||
option_theme.ansi_fg_yellow(),
|
||||
];
|
||||
|
||||
let selected_index_for_action = index;
|
||||
|
||||
let button = Hoverable::new(mouse_state, move |_| {
|
||||
let swatches = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_children(
|
||||
swatch_colors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, color)| {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background_color(*color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
SWATCH_RADIUS_PX,
|
||||
)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(SWATCH_DIAMETER_PX)
|
||||
.with_height(SWATCH_DIAMETER_PX)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(if i > 0 { 4. } else { 0. })
|
||||
.finish()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.paragraph(theme_name_owned.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_weight: Some(Weight::Medium),
|
||||
font_color: Some(text_color.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(swatches)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(16.)
|
||||
.with_horizontal_padding(24.)
|
||||
.with_background(background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(56.)
|
||||
.finish()
|
||||
});
|
||||
|
||||
let button = if interactive {
|
||||
button
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ThemePickerSlideAction::SelectTheme {
|
||||
index: selected_index_for_action,
|
||||
});
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
button.finish()
|
||||
};
|
||||
|
||||
Container::new(button).with_margin_bottom(12.).finish()
|
||||
}
|
||||
|
||||
/// All onboarding image paths used by the theme picker slide visual.
|
||||
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
|
||||
// Terminal intention
|
||||
"async/png/onboarding/terminal_intention/theme/theme_phenomenon_vertical.png",
|
||||
"async/png/onboarding/terminal_intention/theme/theme_phenomenon_horizontal.png",
|
||||
"async/png/onboarding/terminal_intention/theme/theme_dark_vertical.png",
|
||||
"async/png/onboarding/terminal_intention/theme/theme_dark_horizontal.png",
|
||||
"async/png/onboarding/terminal_intention/theme/theme_light_vertical.png",
|
||||
"async/png/onboarding/terminal_intention/theme/theme_light_horizontal.png",
|
||||
"async/png/onboarding/terminal_intention/theme/theme_adeberry_vertical.png",
|
||||
"async/png/onboarding/terminal_intention/theme/theme_adeberry_horizontal.png",
|
||||
// Agent intention
|
||||
"async/png/onboarding/agent_intention/theme/theme_phenomenon_vertical.png",
|
||||
"async/png/onboarding/agent_intention/theme/theme_phenomenon_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/theme/theme_dark_vertical.png",
|
||||
"async/png/onboarding/agent_intention/theme/theme_dark_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/theme/theme_light_vertical.png",
|
||||
"async/png/onboarding/agent_intention/theme/theme_light_horizontal.png",
|
||||
"async/png/onboarding/agent_intention/theme/theme_adeberry_vertical.png",
|
||||
"async/png/onboarding/agent_intention/theme/theme_adeberry_horizontal.png",
|
||||
];
|
||||
|
||||
fn theme_visual_path(&self, app: &AppContext) -> &'static str {
|
||||
let state = self.onboarding_state.as_ref(app);
|
||||
let vertical = state.ui_customization().use_vertical_tabs;
|
||||
let intention_dir = match state.intention() {
|
||||
OnboardingIntention::AgentDrivenDevelopment => "agent_intention",
|
||||
OnboardingIntention::Terminal => "terminal_intention",
|
||||
};
|
||||
let theme_name = self.theme_display_name(self.selected_theme_index);
|
||||
let name_key = match theme_name.as_str() {
|
||||
"Phenomenon" => "phenomenon",
|
||||
"Dark" => "dark",
|
||||
"Light" => "light",
|
||||
"Adeberry" => "adeberry",
|
||||
_ => "dark",
|
||||
};
|
||||
let orientation = if vertical { "vertical" } else { "horizontal" };
|
||||
// Safety: all combinations are in VISUAL_IMAGE_PATHS.
|
||||
Self::VISUAL_IMAGE_PATHS
|
||||
.iter()
|
||||
.find(|p| p.contains(intention_dir) && p.contains(name_key) && p.contains(orientation))
|
||||
.unwrap_or(&Self::VISUAL_IMAGE_PATHS[0])
|
||||
}
|
||||
|
||||
fn render_theme_picker_visual(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
let path = self.theme_visual_path(app);
|
||||
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_DEFAULT)
|
||||
} else {
|
||||
theme_picker_visual(appearance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ThemePickerSlide {
|
||||
type Event = ThemePickerSlideEvent;
|
||||
}
|
||||
|
||||
impl View for ThemePickerSlide {
|
||||
fn ui_name() -> &'static str {
|
||||
"ThemePickerSlide"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
// Background is rendered by the parent onboarding view (including background images).
|
||||
layout::static_left(
|
||||
|| self.render_theme_picker_content(appearance, app),
|
||||
|| self.render_theme_picker_visual(appearance, app),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ThemePickerSlide {
|
||||
fn render_sync_with_os_section(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let background_for_text = theme.background().into_solid();
|
||||
|
||||
let checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(self.sync_with_os_mouse.clone(), Some(12.))
|
||||
.check(self.sync_with_os)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ThemePickerSlideAction::ToggleSyncWithOs)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let label = Text::new(
|
||||
"Sync light/dark theme with OS",
|
||||
appearance.ui_font_family(),
|
||||
14.0,
|
||||
)
|
||||
.with_color(internal_colors::text_sub(theme, background_for_text))
|
||||
.with_style(Properties {
|
||||
weight: Weight::Normal,
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(checkbox)
|
||||
.with_child(Container::new(label).with_margin_left(8.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_disclaimer_section(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let sub_text_color = internal_colors::text_sub(theme, theme.background().into_solid());
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let disclaimer_styles = UiComponentStyles {
|
||||
font_color: Some(sub_text_color),
|
||||
font_size: Some(12.),
|
||||
..Default::default()
|
||||
};
|
||||
let link_styles = UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// The disclaimer block is only rendered on the Terminal-without-Drive
|
||||
// path (see `render_theme_picker_content`), where AI is not part of the
|
||||
// selected onboarding settings; skip the "and AI features" wording.
|
||||
let privacy_line = Flex::row()
|
||||
.with_child(
|
||||
ui_builder
|
||||
.span("If you'd like to opt out of analytics, you can adjust your ")
|
||||
.with_style(disclaimer_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
ui_builder
|
||||
.link(
|
||||
"Privacy Settings".into(),
|
||||
None,
|
||||
Some(Box::new(|ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
ThemePickerSlideAction::PrivacySettingsClicked,
|
||||
);
|
||||
})),
|
||||
self.privacy_settings_mouse_state.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(link_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let tos_line = Flex::row()
|
||||
.with_child(
|
||||
ui_builder
|
||||
.span("By continuing, you agree to Warp's ")
|
||||
.with_style(disclaimer_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
ui_builder
|
||||
.link(
|
||||
"Terms of Service".into(),
|
||||
Some(TOS_URL.into()),
|
||||
None,
|
||||
self.tos_mouse_state.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(link_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(privacy_line)
|
||||
.with_child(Container::new(tos_line).with_margin_top(8.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn select_theme(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
||||
self.sync_with_os = false;
|
||||
self.selected_theme_index = index;
|
||||
let theme_name = self.theme_display_name(index);
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "theme".to_string(),
|
||||
value: theme_name.clone(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
ctx.emit(ThemePickerSlideEvent::ThemeSelected { theme_name });
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn next(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
model.complete(ctx);
|
||||
} else {
|
||||
model.next(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl OnboardingSlide for ThemePickerSlide {
|
||||
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.sync_with_os {
|
||||
return;
|
||||
}
|
||||
let selected_theme_index = self.selected_theme_index;
|
||||
let theme_options_len = self.theme_options.len();
|
||||
|
||||
let up_index = if selected_theme_index == 0 {
|
||||
theme_options_len.saturating_sub(1)
|
||||
} else {
|
||||
selected_theme_index - 1
|
||||
};
|
||||
|
||||
self.select_theme(up_index, ctx);
|
||||
}
|
||||
|
||||
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.sync_with_os {
|
||||
return;
|
||||
}
|
||||
let selected_theme_index = self.selected_theme_index;
|
||||
let theme_options_len = self.theme_options.len();
|
||||
|
||||
let down_index = if selected_theme_index + 1 >= theme_options_len {
|
||||
0
|
||||
} else {
|
||||
selected_theme_index + 1
|
||||
};
|
||||
|
||||
self.select_theme(down_index, ctx);
|
||||
}
|
||||
|
||||
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.next(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ThemePickerSlide {
|
||||
type Action = ThemePickerSlideAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ThemePickerSlideAction::SelectTheme { index } => {
|
||||
if !self.sync_with_os {
|
||||
self.select_theme(*index, ctx);
|
||||
}
|
||||
}
|
||||
ThemePickerSlideAction::ToggleSyncWithOs => {
|
||||
self.sync_with_os = !self.sync_with_os;
|
||||
send_telemetry_from_ctx!(
|
||||
OnboardingEvent::SettingChanged {
|
||||
setting: "sync_with_os".to_string(),
|
||||
value: self.sync_with_os.to_string(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
ctx.emit(ThemePickerSlideEvent::SyncWithOsToggled {
|
||||
enabled: self.sync_with_os,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
ThemePickerSlideAction::BackClicked => {
|
||||
let onboarding_state = self.onboarding_state.clone();
|
||||
onboarding_state.update(ctx, |model, ctx| {
|
||||
model.back(ctx);
|
||||
});
|
||||
}
|
||||
ThemePickerSlideAction::NextClicked => {
|
||||
self.next(ctx);
|
||||
}
|
||||
ThemePickerSlideAction::PrivacySettingsClicked => {
|
||||
ctx.emit(ThemePickerSlideEvent::PrivacySettingsRequested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
use super::toggle_card::{render_toggle_card, ToggleCardSpec};
|
||||
use super::OnboardingSlide;
|
||||
use crate::model::{OnboardingStateEvent, OnboardingStateModel};
|
||||
use crate::slides::{bottom_nav, layout, slide_content};
|
||||
use crate::OnboardingIntention;
|
||||
|
||||
use ui_components::{button, Component as _, Options as _};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warpui::prelude::Align;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ClippedScrollStateHandle, Container, CrossAxisAlignment, Flex, FormattedTextElement,
|
||||
MainAxisSize, MouseStateHandle, ParentElement,
|
||||
},
|
||||
fonts::Weight,
|
||||
keymap::Keystroke,
|
||||
text_layout::TextAlignment,
|
||||
ui_components::components::{UiComponent as _, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
/// Which setting card is currently expanded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SettingCard {
|
||||
CliToolbar,
|
||||
Notifications,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ThirdPartySlideAction {
|
||||
SelectSettingCard { card: SettingCard },
|
||||
SetCliAgentToolbarEnabled { enabled: bool },
|
||||
SetShowAgentNotifications { enabled: bool },
|
||||
BackClicked,
|
||||
NextClicked,
|
||||
}
|
||||
|
||||
pub struct ThirdPartySlide {
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
selected_setting: Option<SettingCard>,
|
||||
cli_toolbar_card_mouse_state: MouseStateHandle,
|
||||
notifications_card_mouse_state: MouseStateHandle,
|
||||
cli_toolbar_seg_left_mouse: MouseStateHandle,
|
||||
cli_toolbar_seg_right_mouse: MouseStateHandle,
|
||||
notifications_seg_left_mouse: MouseStateHandle,
|
||||
notifications_seg_right_mouse: MouseStateHandle,
|
||||
back_button: button::Button,
|
||||
next_button: button::Button,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl ThirdPartySlide {
|
||||
pub(crate) fn new(
|
||||
onboarding_state: ModelHandle<OnboardingStateModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&onboarding_state, |_me, _model, event, ctx| {
|
||||
if matches!(event, OnboardingStateEvent::IntentionChanged) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
onboarding_state,
|
||||
selected_setting: None,
|
||||
cli_toolbar_card_mouse_state: MouseStateHandle::default(),
|
||||
notifications_card_mouse_state: MouseStateHandle::default(),
|
||||
cli_toolbar_seg_left_mouse: MouseStateHandle::default(),
|
||||
cli_toolbar_seg_right_mouse: MouseStateHandle::default(),
|
||||
notifications_seg_left_mouse: MouseStateHandle::default(),
|
||||
notifications_seg_right_mouse: MouseStateHandle::default(),
|
||||
back_button: button::Button::default(),
|
||||
next_button: button::Button::default(),
|
||||
scroll_state: ClippedScrollStateHandle::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// All onboarding image paths used by this slide's visual.
|
||||
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
|
||||
"async/png/onboarding/thirdparty_toolbar_enabled_vertical.png",
|
||||
"async/png/onboarding/thirdparty_toolbar_enabled_horizontal.png",
|
||||
"async/png/onboarding/thirdparty_toolbar_disabled_vertical.png",
|
||||
"async/png/onboarding/thirdparty_toolbar_disabled_horizontal.png",
|
||||
"async/png/onboarding/thirdparty_notifications_enabled.png",
|
||||
"async/png/onboarding/thirdparty_notifications_disabled.png",
|
||||
];
|
||||
|
||||
fn cli_agent_toolbar_enabled(&self, app: &AppContext) -> bool {
|
||||
self.onboarding_state
|
||||
.as_ref(app)
|
||||
.agent_settings()
|
||||
.cli_agent_toolbar_enabled
|
||||
}
|
||||
|
||||
fn show_agent_notifications(&self, app: &AppContext) -> bool {
|
||||
self.onboarding_state
|
||||
.as_ref(app)
|
||||
.agent_settings()
|
||||
.show_agent_notifications
|
||||
}
|
||||
|
||||
fn model_intention(&self, app: &AppContext) -> OnboardingIntention {
|
||||
*self.onboarding_state.as_ref(app).intention()
|
||||
}
|
||||
|
||||
fn render_content(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
cli_toolbar_enabled: bool,
|
||||
show_agent_notifications: bool,
|
||||
intention: OnboardingIntention,
|
||||
) -> Box<dyn Element> {
|
||||
let bottom_nav = Align::new(self.render_bottom_nav(appearance, intention)).finish();
|
||||
|
||||
let mut sections = vec![
|
||||
self.render_header(appearance),
|
||||
self.render_toolbar_section(appearance, cli_toolbar_enabled),
|
||||
];
|
||||
|
||||
// Only show the notifications toggle for terminal intention.
|
||||
// For agent intention, notifications are always enabled.
|
||||
if matches!(intention, OnboardingIntention::Terminal) {
|
||||
sections.push(self.render_notifications_section(appearance, show_agent_notifications));
|
||||
}
|
||||
|
||||
slide_content::onboarding_slide_content(
|
||||
sections,
|
||||
bottom_nav,
|
||||
self.scroll_state.clone(),
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let title = appearance
|
||||
.ui_builder()
|
||||
.paragraph("Customize third party agents")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(36.),
|
||||
font_weight: Some(Weight::Medium),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let subtitle = FormattedTextElement::from_str(
|
||||
"Select defaults for using agents like Claude Code, Codex, and Gemini.",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(internal_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background().into_solid(),
|
||||
))
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Left)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(subtitle).with_margin_top(16.).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_toolbar_section(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
cli_toolbar_enabled: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let is_selected = self.selected_setting == Some(SettingCard::CliToolbar);
|
||||
|
||||
let card = render_toggle_card(
|
||||
appearance,
|
||||
ToggleCardSpec {
|
||||
title: "CLI agent toolbar",
|
||||
is_expanded: is_selected,
|
||||
is_left_selected: cli_toolbar_enabled,
|
||||
left_label: "Enabled",
|
||||
right_label: "Disabled",
|
||||
card_mouse_state: self.cli_toolbar_card_mouse_state.clone(),
|
||||
on_expand: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ThirdPartySlideAction::SelectSettingCard {
|
||||
card: SettingCard::CliToolbar,
|
||||
});
|
||||
}),
|
||||
left_mouse: self.cli_toolbar_seg_left_mouse.clone(),
|
||||
right_mouse: self.cli_toolbar_seg_right_mouse.clone(),
|
||||
on_left: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ThirdPartySlideAction::SetCliAgentToolbarEnabled {
|
||||
enabled: true,
|
||||
});
|
||||
}),
|
||||
on_right: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ThirdPartySlideAction::SetCliAgentToolbarEnabled {
|
||||
enabled: false,
|
||||
});
|
||||
}),
|
||||
chips: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(card)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(40.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_notifications_section(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
show_agent_notifications: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let is_selected = self.selected_setting == Some(SettingCard::Notifications);
|
||||
|
||||
let card = render_toggle_card(
|
||||
appearance,
|
||||
ToggleCardSpec {
|
||||
title: "Notifications",
|
||||
is_expanded: is_selected,
|
||||
is_left_selected: show_agent_notifications,
|
||||
left_label: "Enabled",
|
||||
right_label: "Disabled",
|
||||
card_mouse_state: self.notifications_card_mouse_state.clone(),
|
||||
on_expand: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ThirdPartySlideAction::SelectSettingCard {
|
||||
card: SettingCard::Notifications,
|
||||
});
|
||||
}),
|
||||
left_mouse: self.notifications_seg_left_mouse.clone(),
|
||||
right_mouse: self.notifications_seg_right_mouse.clone(),
|
||||
on_left: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ThirdPartySlideAction::SetShowAgentNotifications {
|
||||
enabled: true,
|
||||
});
|
||||
}),
|
||||
on_right: Box::new(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ThirdPartySlideAction::SetShowAgentNotifications {
|
||||
enabled: false,
|
||||
});
|
||||
}),
|
||||
chips: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(card)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_bottom_nav(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
intention: OnboardingIntention,
|
||||
) -> 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(ThirdPartySlideAction::BackClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let enter = Keystroke::parse("enter").unwrap_or_default();
|
||||
let next_button = self.next_button.render(
|
||||
appearance,
|
||||
button::Params {
|
||||
content: button::Content::Label("Next".into()),
|
||||
theme: &button::themes::Primary,
|
||||
options: button::Options {
|
||||
keystroke: Some(enter),
|
||||
on_click: Some(Box::new(|ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(ThirdPartySlideAction::NextClicked);
|
||||
})),
|
||||
..button::Options::default(appearance)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let is_terminal = matches!(intention, OnboardingIntention::Terminal);
|
||||
let (step_index, step_count) = if is_terminal { (2, 4) } else { (3, 5) };
|
||||
bottom_nav::onboarding_bottom_nav(
|
||||
appearance,
|
||||
step_index,
|
||||
step_count,
|
||||
Some(back_button),
|
||||
Some(next_button),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_visual(
|
||||
&self,
|
||||
cli_toolbar_enabled: bool,
|
||||
show_agent_notifications: bool,
|
||||
vertical: bool,
|
||||
) -> Box<dyn Element> {
|
||||
if self.selected_setting == Some(SettingCard::Notifications) {
|
||||
let path = if show_agent_notifications {
|
||||
Self::VISUAL_IMAGE_PATHS[4]
|
||||
} else {
|
||||
Self::VISUAL_IMAGE_PATHS[5]
|
||||
};
|
||||
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_CODE_REVIEW)
|
||||
} else {
|
||||
let path = match (cli_toolbar_enabled, vertical) {
|
||||
(true, true) => Self::VISUAL_IMAGE_PATHS[0],
|
||||
(true, false) => Self::VISUAL_IMAGE_PATHS[1],
|
||||
(false, true) => Self::VISUAL_IMAGE_PATHS[2],
|
||||
(false, false) => Self::VISUAL_IMAGE_PATHS[3],
|
||||
};
|
||||
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_THIRD_PARTY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ThirdPartySlide {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for ThirdPartySlide {
|
||||
fn ui_name() -> &'static str {
|
||||
"ThirdPartySlide"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let cli_toolbar_enabled = self.cli_agent_toolbar_enabled(app);
|
||||
let show_agent_notifications = self.show_agent_notifications(app);
|
||||
let intention = self.model_intention(app);
|
||||
let vertical = self
|
||||
.onboarding_state
|
||||
.as_ref(app)
|
||||
.ui_customization()
|
||||
.use_vertical_tabs;
|
||||
|
||||
layout::static_left(
|
||||
|| {
|
||||
self.render_content(
|
||||
appearance,
|
||||
cli_toolbar_enabled,
|
||||
show_agent_notifications,
|
||||
intention,
|
||||
)
|
||||
},
|
||||
|| self.render_visual(cli_toolbar_enabled, show_agent_notifications, vertical),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ThirdPartySlide {
|
||||
fn select_setting_card(&mut self, card: SettingCard, ctx: &mut ViewContext<Self>) {
|
||||
self.selected_setting = Some(card);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn next(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.next(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl OnboardingSlide for ThirdPartySlide {
|
||||
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let new_card = match self.selected_setting {
|
||||
None => SettingCard::CliToolbar,
|
||||
Some(SettingCard::CliToolbar) => SettingCard::CliToolbar,
|
||||
Some(SettingCard::Notifications) => SettingCard::CliToolbar,
|
||||
};
|
||||
self.selected_setting = Some(new_card);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let is_terminal = matches!(self.model_intention(ctx), OnboardingIntention::Terminal);
|
||||
let new_card = match self.selected_setting {
|
||||
None => SettingCard::CliToolbar,
|
||||
Some(SettingCard::CliToolbar) => {
|
||||
if is_terminal {
|
||||
SettingCard::Notifications
|
||||
} else {
|
||||
SettingCard::CliToolbar
|
||||
}
|
||||
}
|
||||
Some(SettingCard::Notifications) => SettingCard::Notifications,
|
||||
};
|
||||
self.selected_setting = Some(new_card);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn on_left(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
match self.selected_setting {
|
||||
Some(SettingCard::CliToolbar) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_cli_agent_toolbar_enabled(true, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Some(SettingCard::Notifications) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_show_agent_notifications(true, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_right(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
match self.selected_setting {
|
||||
Some(SettingCard::CliToolbar) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_cli_agent_toolbar_enabled(false, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Some(SettingCard::Notifications) => {
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_show_agent_notifications(false, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.next(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ThirdPartySlide {
|
||||
type Action = ThirdPartySlideAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ThirdPartySlideAction::SelectSettingCard { card } => {
|
||||
self.select_setting_card(*card, ctx);
|
||||
}
|
||||
ThirdPartySlideAction::SetCliAgentToolbarEnabled { enabled } => {
|
||||
let value = *enabled;
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_cli_agent_toolbar_enabled(value, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
ThirdPartySlideAction::SetShowAgentNotifications { enabled } => {
|
||||
let value = *enabled;
|
||||
self.onboarding_state.update(ctx, |model, ctx| {
|
||||
model.set_show_agent_notifications(value, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
ThirdPartySlideAction::BackClicked => {
|
||||
let onboarding_state = self.onboarding_state.clone();
|
||||
onboarding_state.update(ctx, |model, ctx| {
|
||||
model.back(ctx);
|
||||
});
|
||||
}
|
||||
ThirdPartySlideAction::NextClicked => {
|
||||
self.next(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
|
||||
use warpui::prelude::Align;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
|
||||
FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
ParentElement, Radius, Shrinkable, Text, Wrap,
|
||||
},
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
presenter::EventContext,
|
||||
text_layout::TextAlignment,
|
||||
AppContext, Element,
|
||||
};
|
||||
|
||||
pub(super) type ClickCallback = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) + 'static>;
|
||||
pub(super) type HoverCallback =
|
||||
Box<dyn FnMut(bool, &mut EventContext, &AppContext, Vector2F) + 'static>;
|
||||
|
||||
pub(super) struct ChipSpec {
|
||||
pub label: &'static str,
|
||||
pub is_enabled: bool,
|
||||
pub mouse_state: MouseStateHandle,
|
||||
pub on_click: ClickCallback,
|
||||
pub on_hover: Option<HoverCallback>,
|
||||
}
|
||||
|
||||
pub(super) struct ToggleCardSpec {
|
||||
pub title: &'static str,
|
||||
pub is_expanded: bool,
|
||||
pub is_left_selected: bool,
|
||||
pub left_label: &'static str,
|
||||
pub right_label: &'static str,
|
||||
pub card_mouse_state: MouseStateHandle,
|
||||
pub on_expand: ClickCallback,
|
||||
pub left_mouse: MouseStateHandle,
|
||||
pub right_mouse: MouseStateHandle,
|
||||
pub on_left: ClickCallback,
|
||||
pub on_right: ClickCallback,
|
||||
pub chips: Vec<ChipSpec>,
|
||||
}
|
||||
|
||||
pub(super) fn render_toggle_card(
|
||||
appearance: &Appearance,
|
||||
spec: ToggleCardSpec,
|
||||
) -> Box<dyn Element> {
|
||||
if spec.is_expanded {
|
||||
render_expanded(appearance, spec)
|
||||
} else {
|
||||
render_collapsed(appearance, spec)
|
||||
}
|
||||
}
|
||||
|
||||
fn collapsed_subtitle(
|
||||
is_enabled: bool,
|
||||
left_label: &str,
|
||||
right_label: &str,
|
||||
chips: &[ChipSpec],
|
||||
) -> String {
|
||||
if !is_enabled {
|
||||
return right_label.to_string();
|
||||
}
|
||||
if chips.is_empty() {
|
||||
return left_label.to_string();
|
||||
}
|
||||
let enabled_labels: Vec<&str> = chips
|
||||
.iter()
|
||||
.filter(|c| c.is_enabled)
|
||||
.map(|c| c.label)
|
||||
.collect();
|
||||
if enabled_labels.is_empty() {
|
||||
return left_label.to_string();
|
||||
}
|
||||
let joined = enabled_labels.join(", ");
|
||||
let mut chars = joined.chars();
|
||||
match chars.next() {
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_collapsed(appearance: &Appearance, spec: ToggleCardSpec) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
let text_color = internal_colors::text_sub(theme, theme.background().into_solid());
|
||||
let border_color = Fill::Solid(internal_colors::neutral_4(theme));
|
||||
let subtitle = collapsed_subtitle(
|
||||
spec.is_left_selected,
|
||||
spec.left_label,
|
||||
spec.right_label,
|
||||
&spec.chips,
|
||||
);
|
||||
let mut on_expand = spec.on_expand;
|
||||
|
||||
Hoverable::new(spec.card_mouse_state, move |_| {
|
||||
let title_el = FormattedTextElement::from_str(spec.title, ui_font_family, 16.)
|
||||
.with_color(text_color)
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Left)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
let sub_el = Text::new(subtitle.clone(), ui_font_family, 12.)
|
||||
.with_color(text_color)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
let content = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title_el)
|
||||
.with_child(Container::new(sub_el).with_margin_top(12.).finish())
|
||||
.finish();
|
||||
|
||||
Container::new(content)
|
||||
.with_uniform_padding(24.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, app, pos| {
|
||||
on_expand(ctx, app, pos);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_expanded(appearance: &Appearance, spec: ToggleCardSpec) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
let text_color = internal_colors::text_main(theme, theme.background().into_solid());
|
||||
let border_color = theme.accent();
|
||||
let background = internal_colors::accent_overlay_1(theme);
|
||||
|
||||
let title_el = FormattedTextElement::from_str(spec.title, ui_font_family, 16.)
|
||||
.with_color(text_color)
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Left)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
let seg_control = render_inline_segmented_control(
|
||||
appearance,
|
||||
spec.is_left_selected,
|
||||
spec.left_label,
|
||||
spec.right_label,
|
||||
spec.left_mouse,
|
||||
spec.right_mouse,
|
||||
spec.on_left,
|
||||
spec.on_right,
|
||||
);
|
||||
|
||||
let mut content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(title_el)
|
||||
.with_child(Container::new(seg_control).with_margin_top(12.).finish());
|
||||
|
||||
if !spec.chips.is_empty() {
|
||||
let chips_el = render_chips(appearance, spec.chips);
|
||||
content = content.with_child(Container::new(chips_el).with_margin_top(12.).finish());
|
||||
}
|
||||
|
||||
Container::new(content.finish())
|
||||
.with_uniform_padding(24.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||
.with_background(background)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn render_inline_segmented_control(
|
||||
appearance: &Appearance,
|
||||
is_left_selected: bool,
|
||||
left_label: &'static str,
|
||||
right_label: &'static str,
|
||||
enabled_mouse: MouseStateHandle,
|
||||
disabled_mouse: MouseStateHandle,
|
||||
on_left: ClickCallback,
|
||||
on_right: ClickCallback,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
let selected_bg = internal_colors::accent_overlay_3(theme);
|
||||
let text_main = internal_colors::text_main(theme, theme.background().into_solid());
|
||||
let text_sub = internal_colors::text_sub(theme, theme.background().into_solid());
|
||||
let control_bg = internal_colors::fg_overlay_1(theme);
|
||||
|
||||
let build_option = move |label: &'static str,
|
||||
is_selected: bool,
|
||||
mouse: MouseStateHandle,
|
||||
mut callback: ClickCallback| {
|
||||
let option = Hoverable::new(mouse, move |_| {
|
||||
let label_el = FormattedTextElement::from_str(label, ui_font_family, 14.)
|
||||
.with_color(if is_selected { text_main } else { text_sub })
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Center)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
let aligned = Align::new(label_el).finish();
|
||||
|
||||
let mut container = Container::new(aligned)
|
||||
.with_padding_left(8.)
|
||||
.with_padding_right(8.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
|
||||
|
||||
if is_selected {
|
||||
container = container.with_background(selected_bg);
|
||||
}
|
||||
|
||||
container.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, app, pos| {
|
||||
callback(ctx, app, pos);
|
||||
})
|
||||
.finish();
|
||||
Shrinkable::new(1., option).finish()
|
||||
};
|
||||
|
||||
let left = build_option(left_label, is_left_selected, enabled_mouse, on_left);
|
||||
let right = build_option(right_label, !is_left_selected, disabled_mouse, on_right);
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(left)
|
||||
.with_child(right)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(24.)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(4.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_background(control_bg)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_chips(appearance: &Appearance, chips: Vec<ChipSpec>) -> Box<dyn Element> {
|
||||
let mut wrap = Wrap::row().with_spacing(12.).with_run_spacing(12.);
|
||||
wrap.extend(chips.into_iter().map(|chip| render_chip(appearance, chip)));
|
||||
wrap.finish()
|
||||
}
|
||||
|
||||
fn render_chip(appearance: &Appearance, mut chip: ChipSpec) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
|
||||
let (bg, border) = if chip.is_enabled {
|
||||
(
|
||||
Some(internal_colors::accent_overlay_2(theme)),
|
||||
Some(theme.accent()),
|
||||
)
|
||||
} else {
|
||||
(Some(internal_colors::fg_overlay_1(theme)), None)
|
||||
};
|
||||
|
||||
let text_color = if chip.is_enabled {
|
||||
internal_colors::text_main(theme, theme.background().into_solid())
|
||||
} else {
|
||||
internal_colors::text_sub(theme, theme.background().into_solid())
|
||||
};
|
||||
|
||||
let label = chip.label;
|
||||
|
||||
let mut hoverable = Hoverable::new(chip.mouse_state, move |_| {
|
||||
let label_el = FormattedTextElement::from_str(label, ui_font_family, 14.)
|
||||
.with_color(text_color)
|
||||
.with_weight(Weight::Normal)
|
||||
.with_alignment(TextAlignment::Center)
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
let mut container = Container::new(Align::new(label_el).finish())
|
||||
.with_padding_left(12.)
|
||||
.with_padding_right(12.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
|
||||
|
||||
if let Some(bg) = bg {
|
||||
container = container.with_background(bg);
|
||||
}
|
||||
let border_fill = border.unwrap_or(Fill::Solid(pathfinder_color::ColorU::new(0, 0, 0, 0)));
|
||||
container = container.with_border(Border::all(1.).with_border_fill(border_fill));
|
||||
|
||||
ConstrainedBox::new(container.finish())
|
||||
.with_height(32.)
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, app, pos| {
|
||||
(chip.on_click)(ctx, app, pos);
|
||||
});
|
||||
|
||||
if let Some(mut hover_cb) = chip.on_hover {
|
||||
hoverable = hoverable.on_hover(move |is_hovered, ctx, app, pos| {
|
||||
hover_cb(is_hovered, ctx, app, pos);
|
||||
});
|
||||
}
|
||||
|
||||
hoverable.finish()
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use super::agent_slide::AgentSlideAction;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::{
|
||||
appearance::Appearance,
|
||||
icons::Icon,
|
||||
theme::{color::internal_colors, Fill},
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Flex, Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
platform::Cursor,
|
||||
Element,
|
||||
};
|
||||
|
||||
pub(super) struct TwoLineButtonSpec {
|
||||
pub(super) is_selected: bool,
|
||||
pub(super) title: String,
|
||||
pub(super) subtitle: String,
|
||||
pub(super) height: f32,
|
||||
pub(super) mouse_state: MouseStateHandle,
|
||||
pub(super) click_action: AgentSlideAction,
|
||||
pub(super) subtitle_font_size: f32,
|
||||
pub(super) title_color: Fill,
|
||||
pub(super) subtitle_color: Fill,
|
||||
/// Optional icon to display before the title.
|
||||
pub(super) icon: Option<Icon>,
|
||||
/// If set, the button is disabled and this text is shown as a badge.
|
||||
pub(super) disabled_badge: Option<String>,
|
||||
/// When true, the button is fully disabled: muted colors, no selected
|
||||
/// state, no click cursor, and no click handler.
|
||||
pub(super) is_disabled: bool,
|
||||
}
|
||||
|
||||
pub(super) fn render_two_line_button(
|
||||
appearance: &Appearance,
|
||||
spec: TwoLineButtonSpec,
|
||||
) -> Box<dyn Element> {
|
||||
const RADIUS: f32 = 8.;
|
||||
|
||||
let TwoLineButtonSpec {
|
||||
is_selected,
|
||||
title,
|
||||
subtitle,
|
||||
height,
|
||||
mouse_state,
|
||||
click_action,
|
||||
subtitle_font_size,
|
||||
title_color,
|
||||
subtitle_color,
|
||||
icon,
|
||||
disabled_badge,
|
||||
is_disabled,
|
||||
} = spec;
|
||||
|
||||
let theme = appearance.theme();
|
||||
let is_disabled = is_disabled || disabled_badge.is_some();
|
||||
|
||||
// Disabled models always use muted colors and never show as selected.
|
||||
let effective_selected = is_selected && !is_disabled;
|
||||
|
||||
let (title_fill, subtitle_fill, background) = match (effective_selected, is_disabled) {
|
||||
(true, _) => {
|
||||
let bg_color = internal_colors::accent_overlay_1(theme);
|
||||
let selected_color = internal_colors::accent_fg_strong(theme);
|
||||
(selected_color, selected_color, Some(bg_color))
|
||||
}
|
||||
(false, true) => {
|
||||
let bg_color = theme.surface_2();
|
||||
let disabled_color = theme.disabled_text_color(bg_color);
|
||||
(disabled_color, disabled_color, Some(bg_color))
|
||||
}
|
||||
(false, false) => (title_color, subtitle_color, None),
|
||||
};
|
||||
|
||||
let border_color = if effective_selected {
|
||||
theme.accent()
|
||||
} else {
|
||||
Fill::Solid(internal_colors::neutral_4(theme))
|
||||
};
|
||||
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
|
||||
let hoverable = Hoverable::new(mouse_state, move |_| {
|
||||
let title_text = Text::new(title.clone(), ui_font_family, 14.0)
|
||||
.with_color(title_fill.into_solid())
|
||||
.with_style(Properties {
|
||||
weight: Weight::Normal,
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
// Build title row with optional icon
|
||||
let title_el: Box<dyn Element> = if let Some(icon) = icon {
|
||||
const ICON_SIZE: f32 = 14.;
|
||||
let icon_el = ConstrainedBox::new(Box::new(icon.to_warpui_icon(title_fill)))
|
||||
.with_width(ICON_SIZE)
|
||||
.with_height(ICON_SIZE)
|
||||
.finish();
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(icon_el)
|
||||
.with_child(Container::new(title_text).with_margin_left(4.).finish())
|
||||
.finish()
|
||||
} else {
|
||||
title_text
|
||||
};
|
||||
|
||||
let subtitle_el = Text::new(subtitle.clone(), ui_font_family, subtitle_font_size)
|
||||
.with_color(subtitle_fill.into_solid())
|
||||
.with_style(Properties {
|
||||
weight: Weight::Normal,
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
let content = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(title_el)
|
||||
.with_child(Container::new(subtitle_el).with_margin_top(8.).finish())
|
||||
.finish();
|
||||
|
||||
let aligned = Align::new(content).left().finish();
|
||||
|
||||
let mut container = Container::new(aligned)
|
||||
.with_uniform_padding(24.)
|
||||
.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);
|
||||
}
|
||||
|
||||
let button_el = ConstrainedBox::new(container.finish())
|
||||
.with_min_height(height)
|
||||
.finish();
|
||||
|
||||
// Overlay the badge pill at bottom-right if disabled
|
||||
if let Some(ref badge_text) = disabled_badge {
|
||||
let badge_label = Text::new(badge_text.clone(), ui_font_family, 11.0)
|
||||
.with_color(internal_colors::neutral_1(theme))
|
||||
.with_style(Properties {
|
||||
weight: Weight::Medium,
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.0)
|
||||
.finish();
|
||||
|
||||
let badge = Container::new(badge_label)
|
||||
.with_padding_left(6.)
|
||||
.with_padding_right(6.)
|
||||
.with_padding_top(2.)
|
||||
.with_padding_bottom(2.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background_color(theme.ansi_fg_green())
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(button_el);
|
||||
stack.add_positioned_child(
|
||||
badge,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-6., -6.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::BottomRight,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
} else {
|
||||
button_el
|
||||
}
|
||||
});
|
||||
|
||||
if is_disabled {
|
||||
// Disabled: no click handler, default cursor
|
||||
hoverable.finish()
|
||||
} else {
|
||||
hoverable
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(click_action.clone());
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
|
||||
/// Telemetry events for the onboarding flow.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
#[strum_discriminants(name(OnboardingEventDiscriminant))]
|
||||
pub enum OnboardingEvent {
|
||||
/// The onboarding flow was started.
|
||||
OnboardingStarted,
|
||||
/// A specific slide was viewed.
|
||||
SlideViewed { slide_name: String },
|
||||
/// A setting was changed during onboarding.
|
||||
SettingChanged { setting: String, value: String },
|
||||
/// The onboarding slides were completed.
|
||||
OnboardingSlidesCompleted {
|
||||
intention: String,
|
||||
model: Option<String>,
|
||||
autonomy: Option<String>,
|
||||
has_project_path: bool,
|
||||
},
|
||||
/// The user clicked the "Get Started" button.
|
||||
GetStartedClicked,
|
||||
/// The user started folder selection.
|
||||
FolderSelectionStarted,
|
||||
/// The user selected a folder.
|
||||
FolderSelected,
|
||||
/// A callout was displayed.
|
||||
CalloutDisplayed { callout: String },
|
||||
/// The user clicked next on a callout.
|
||||
CalloutNext,
|
||||
/// The user completed the callout flow.
|
||||
CalloutCompleted { completion_type: String },
|
||||
/// The user navigated to the next slide.
|
||||
SlideNavigatedNext,
|
||||
/// The user navigated to the previous slide.
|
||||
SlideNavigatedBack,
|
||||
/// The user clicked the upgrade/subscribe button on the FreeUserNoAi experiment slide.
|
||||
FreeUserNoAiUpgradeClicked,
|
||||
/// The user clicked the "Upgrade" button on the "Customize your agent" slide.
|
||||
AgentSlideUpgradeClicked,
|
||||
/// The user clicked the "Log in" link on the welcome/intro slide.
|
||||
WelcomeLoginClicked,
|
||||
}
|
||||
|
||||
impl TelemetryEvent for OnboardingEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
OnboardingEvent::OnboardingStarted => "onboarding_started",
|
||||
OnboardingEvent::SlideViewed { .. } => "onboarding_slide_viewed",
|
||||
OnboardingEvent::SettingChanged { .. } => "onboarding_setting_changed",
|
||||
OnboardingEvent::OnboardingSlidesCompleted { .. } => "onboarding_slides_completed",
|
||||
OnboardingEvent::GetStartedClicked => "onboarding_get_started_clicked",
|
||||
OnboardingEvent::FolderSelectionStarted => "onboarding_folder_selection_started",
|
||||
OnboardingEvent::FolderSelected => "onboarding_folder_selected",
|
||||
OnboardingEvent::CalloutDisplayed { .. } => "onboarding_callout_displayed",
|
||||
OnboardingEvent::CalloutNext => "onboarding_callout_next",
|
||||
OnboardingEvent::CalloutCompleted { .. } => "onboarding_callout_completed",
|
||||
OnboardingEvent::SlideNavigatedNext => "onboarding_slide_navigated_next",
|
||||
OnboardingEvent::SlideNavigatedBack => "onboarding_slide_navigated_back",
|
||||
OnboardingEvent::FreeUserNoAiUpgradeClicked => {
|
||||
"onboarding_free_user_no_ai_upgrade_clicked"
|
||||
}
|
||||
OnboardingEvent::AgentSlideUpgradeClicked => "onboarding_agent_slide_upgrade_clicked",
|
||||
OnboardingEvent::WelcomeLoginClicked => "onboarding_welcome_login_clicked",
|
||||
}
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
OnboardingEvent::OnboardingStarted => None,
|
||||
OnboardingEvent::SlideViewed { slide_name } => Some(json!({
|
||||
"slide_name": slide_name,
|
||||
})),
|
||||
OnboardingEvent::SettingChanged { setting, value } => Some(json!({
|
||||
"setting": setting,
|
||||
"value": value,
|
||||
})),
|
||||
OnboardingEvent::OnboardingSlidesCompleted {
|
||||
intention,
|
||||
model,
|
||||
autonomy,
|
||||
has_project_path,
|
||||
} => Some(json!({
|
||||
"intention": intention,
|
||||
"model": model,
|
||||
"autonomy": autonomy,
|
||||
"has_project_path": has_project_path,
|
||||
})),
|
||||
OnboardingEvent::GetStartedClicked => None,
|
||||
OnboardingEvent::FolderSelectionStarted => None,
|
||||
OnboardingEvent::FolderSelected => None,
|
||||
OnboardingEvent::CalloutDisplayed { callout } => Some(json!({
|
||||
"callout": callout,
|
||||
})),
|
||||
OnboardingEvent::CalloutNext => None,
|
||||
OnboardingEvent::CalloutCompleted { completion_type } => Some(json!({
|
||||
"completion_type": completion_type,
|
||||
})),
|
||||
OnboardingEvent::SlideNavigatedNext => None,
|
||||
OnboardingEvent::SlideNavigatedBack => None,
|
||||
OnboardingEvent::FreeUserNoAiUpgradeClicked => None,
|
||||
OnboardingEvent::AgentSlideUpgradeClicked => None,
|
||||
OnboardingEvent::WelcomeLoginClicked => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
OnboardingEvent::OnboardingStarted => "User started the onboarding flow",
|
||||
OnboardingEvent::SlideViewed { .. } => "User viewed a slide in the onboarding flow",
|
||||
OnboardingEvent::SettingChanged { .. } => "User changed a setting during onboarding",
|
||||
OnboardingEvent::OnboardingSlidesCompleted { .. } => {
|
||||
"User completed the onboarding slides"
|
||||
}
|
||||
OnboardingEvent::GetStartedClicked => "User clicked the Get Started button",
|
||||
OnboardingEvent::FolderSelectionStarted => "User started folder selection",
|
||||
OnboardingEvent::FolderSelected => "User selected a folder",
|
||||
OnboardingEvent::CalloutDisplayed { .. } => "A callout was displayed to the user",
|
||||
OnboardingEvent::CalloutNext => "User clicked next on a callout",
|
||||
OnboardingEvent::CalloutCompleted { .. } => "User completed the callout flow",
|
||||
OnboardingEvent::SlideNavigatedNext => "User navigated to the next slide",
|
||||
OnboardingEvent::SlideNavigatedBack => "User navigated to the previous slide",
|
||||
OnboardingEvent::FreeUserNoAiUpgradeClicked => {
|
||||
"User clicked the upgrade button on the free-user no-AI experiment slide"
|
||||
}
|
||||
OnboardingEvent::AgentSlideUpgradeClicked => {
|
||||
"User clicked the Upgrade button on the Customize your agent slide"
|
||||
}
|
||||
OnboardingEvent::WelcomeLoginClicked => {
|
||||
"User clicked the Log in link on the welcome/intro slide"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
EnablementState::Always
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
warp_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for OnboardingEventDiscriminant {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
OnboardingEventDiscriminant::OnboardingStarted => "onboarding_started",
|
||||
OnboardingEventDiscriminant::SlideViewed => "onboarding_slide_viewed",
|
||||
OnboardingEventDiscriminant::SettingChanged => "onboarding_setting_changed",
|
||||
OnboardingEventDiscriminant::OnboardingSlidesCompleted => "onboarding_slides_completed",
|
||||
OnboardingEventDiscriminant::GetStartedClicked => "onboarding_get_started_clicked",
|
||||
OnboardingEventDiscriminant::FolderSelectionStarted => {
|
||||
"onboarding_folder_selection_started"
|
||||
}
|
||||
OnboardingEventDiscriminant::FolderSelected => "onboarding_folder_selected",
|
||||
OnboardingEventDiscriminant::CalloutDisplayed => "onboarding_callout_displayed",
|
||||
OnboardingEventDiscriminant::CalloutNext => "onboarding_callout_next",
|
||||
OnboardingEventDiscriminant::CalloutCompleted => "onboarding_callout_completed",
|
||||
OnboardingEventDiscriminant::SlideNavigatedNext => "onboarding_slide_navigated_next",
|
||||
OnboardingEventDiscriminant::SlideNavigatedBack => "onboarding_slide_navigated_back",
|
||||
OnboardingEventDiscriminant::FreeUserNoAiUpgradeClicked => {
|
||||
"onboarding_free_user_no_ai_upgrade_clicked"
|
||||
}
|
||||
OnboardingEventDiscriminant::AgentSlideUpgradeClicked => {
|
||||
"onboarding_agent_slide_upgrade_clicked"
|
||||
}
|
||||
OnboardingEventDiscriminant::WelcomeLoginClicked => "onboarding_welcome_login_clicked",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
OnboardingEventDiscriminant::OnboardingStarted => "User started the onboarding flow",
|
||||
OnboardingEventDiscriminant::SlideViewed => {
|
||||
"User viewed a slide in the onboarding flow"
|
||||
}
|
||||
OnboardingEventDiscriminant::SettingChanged => {
|
||||
"User changed a setting during onboarding"
|
||||
}
|
||||
OnboardingEventDiscriminant::OnboardingSlidesCompleted => {
|
||||
"User completed the onboarding slides"
|
||||
}
|
||||
OnboardingEventDiscriminant::GetStartedClicked => "User clicked the Get Started button",
|
||||
OnboardingEventDiscriminant::FolderSelectionStarted => "User started folder selection",
|
||||
OnboardingEventDiscriminant::FolderSelected => "User selected a folder",
|
||||
OnboardingEventDiscriminant::CalloutDisplayed => "A callout was displayed to the user",
|
||||
OnboardingEventDiscriminant::CalloutNext => "User clicked next on a callout",
|
||||
OnboardingEventDiscriminant::CalloutCompleted => "User completed the callout flow",
|
||||
OnboardingEventDiscriminant::SlideNavigatedNext => "User navigated to the next slide",
|
||||
OnboardingEventDiscriminant::SlideNavigatedBack => {
|
||||
"User navigated to the previous slide"
|
||||
}
|
||||
OnboardingEventDiscriminant::FreeUserNoAiUpgradeClicked => {
|
||||
"User clicked the upgrade button on the free-user no-AI experiment slide"
|
||||
}
|
||||
OnboardingEventDiscriminant::AgentSlideUpgradeClicked => {
|
||||
"User clicked the Upgrade button on the Customize your agent slide"
|
||||
}
|
||||
OnboardingEventDiscriminant::WelcomeLoginClicked => {
|
||||
"User clicked the Log in link on the welcome/intro slide"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
EnablementState::Always
|
||||
}
|
||||
}
|
||||
|
||||
warp_core::register_telemetry_event!(OnboardingEvent);
|
||||
@@ -0,0 +1,31 @@
|
||||
use warp_core::telemetry::{TelemetryContextModel, TelemetryContextProvider};
|
||||
use warpui::{AppContext, ModelContext};
|
||||
|
||||
/// A mock telemetry context provider for the onboarding binary that logs events
|
||||
/// instead of sending them to a server.
|
||||
///
|
||||
/// Since the onboarding binary runs standalone without authentication,
|
||||
/// we use a fixed anonymous ID and log all telemetry events.
|
||||
pub struct MockTelemetryContextProvider {
|
||||
anonymous_id: String,
|
||||
}
|
||||
|
||||
impl MockTelemetryContextProvider {
|
||||
pub fn new_context_provider(
|
||||
_ctx: &mut ModelContext<TelemetryContextModel>,
|
||||
) -> TelemetryContextModel {
|
||||
let anonymous_id = uuid::Uuid::new_v4().to_string();
|
||||
Box::new(Self { anonymous_id })
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryContextProvider for MockTelemetryContextProvider {
|
||||
fn user_id(&self, _ctx: &AppContext) -> Option<String> {
|
||||
// No user ID for the standalone onboarding binary
|
||||
None
|
||||
}
|
||||
|
||||
fn anonymous_id(&self, _ctx: &AppContext) -> String {
|
||||
self.anonymous_id.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::elements::Align;
|
||||
use warpui::Element;
|
||||
|
||||
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
|
||||
|
||||
pub(crate) fn agent_visual(
|
||||
panel_background: ColorU,
|
||||
neutral: ColorU,
|
||||
blue: ColorU,
|
||||
green: ColorU,
|
||||
yellow: ColorU,
|
||||
) -> Box<dyn Element> {
|
||||
// X is in percent of the inner panel width; negative values intentionally protrude.
|
||||
const LEFT_PROTRUSION_PCT: f32 = -0.06;
|
||||
const RIGHT_PROTRUSION_PCT: f32 = 0.03;
|
||||
const BAR_H_PCT: f32 = 0.040;
|
||||
|
||||
let mut pills = Vec::new();
|
||||
|
||||
// Top-left neutral bars.
|
||||
let top_left = [(0.38, 0.18), (0.55, 0.24), (0.42, 0.30), (0.48, 0.36)];
|
||||
pills.extend(top_left.into_iter().map(|(w_pct, y_pct)| Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
|
||||
color: neutral,
|
||||
}));
|
||||
|
||||
// Upper-right blue bars.
|
||||
let blue_bars = [(0.26, 0.12), (0.16, 0.18)];
|
||||
pills.extend(blue_bars.into_iter().map(|(w_pct, y_pct)| Pill {
|
||||
rect: RectPct::new(1.0 - w_pct + RIGHT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
|
||||
color: blue,
|
||||
}));
|
||||
|
||||
// Mid section: 3 full-width (with overhang) bars, 2x height, evenly spaced.
|
||||
let full_w = 1.0 + (-LEFT_PROTRUSION_PCT) + RIGHT_PROTRUSION_PCT;
|
||||
let full_x = LEFT_PROTRUSION_PCT;
|
||||
let tall_h = BAR_H_PCT * 2.0;
|
||||
let gap_h = 0.02;
|
||||
|
||||
let mut y = 0.5;
|
||||
for _ in 0..3 {
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(full_x, y, full_w, tall_h),
|
||||
color: neutral,
|
||||
});
|
||||
y += tall_h + gap_h;
|
||||
}
|
||||
|
||||
// Bottom container + rows.
|
||||
const BOTTOM_Y_PCT: f32 = 0.80;
|
||||
const ROW_GAP_PCT: f32 = 0.012;
|
||||
|
||||
let bottom_w = 1.0 + (-LEFT_PROTRUSION_PCT) + RIGHT_PROTRUSION_PCT;
|
||||
|
||||
let row_x = LEFT_PROTRUSION_PCT + 0.02;
|
||||
let row_w = bottom_w - 0.04;
|
||||
let mut row_y = BOTTOM_Y_PCT + 0.03;
|
||||
|
||||
// Row 1: full green.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(row_x, row_y, row_w, BAR_H_PCT),
|
||||
color: green,
|
||||
});
|
||||
row_y += BAR_H_PCT + ROW_GAP_PCT;
|
||||
|
||||
// Row 2: mostly green.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(row_x, row_y, row_w * 0.82, BAR_H_PCT),
|
||||
color: green,
|
||||
});
|
||||
row_y += BAR_H_PCT + ROW_GAP_PCT;
|
||||
|
||||
// Row 3: yellow.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(row_x, row_y, row_w * 0.90, BAR_H_PCT),
|
||||
color: yellow,
|
||||
});
|
||||
row_y += BAR_H_PCT + ROW_GAP_PCT;
|
||||
|
||||
// Row 4: shorter green.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(row_x, row_y, row_w * 0.28, BAR_H_PCT),
|
||||
color: green,
|
||||
});
|
||||
|
||||
Align::new(OnboardingVisual::new(panel_background, pills, true).finish()).finish()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::elements::Align;
|
||||
use warpui::Element;
|
||||
|
||||
use crate::visuals::onboarding_visual::Rect;
|
||||
|
||||
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
|
||||
|
||||
pub(crate) fn intention_terminal_visual(
|
||||
panel_background: ColorU,
|
||||
neutral: ColorU,
|
||||
neutral_highlight: ColorU,
|
||||
accent: ColorU,
|
||||
) -> Box<dyn Element> {
|
||||
// X is in percent of the inner panel width; negative values intentionally protrude.
|
||||
const LEFT_PROTRUSION_PCT: f32 = -0.06;
|
||||
const BAR_H_PCT: f32 = 0.040;
|
||||
// ROW_GAP_PCT: f32 = 0.012; <- This is the gap between rows.
|
||||
|
||||
// All bars are neutral_4 and overhang to the left.
|
||||
let rows = [
|
||||
(0.25, 0.15),
|
||||
(0.50, 0.202),
|
||||
(0.35, 0.254),
|
||||
(0.45, 0.306),
|
||||
(0.70, 0.386),
|
||||
(0.50, 0.438),
|
||||
(0.80, 0.490),
|
||||
];
|
||||
|
||||
let mut pills = rows
|
||||
.into_iter()
|
||||
.map(|(w_pct, y_pct)| Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
|
||||
color: neutral,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.08, 0.6, BAR_H_PCT),
|
||||
color: neutral_highlight,
|
||||
});
|
||||
|
||||
let rects = vec![Rect {
|
||||
rect: RectPct::new(0.75, 0.481, 0.01, 0.058),
|
||||
color: accent,
|
||||
}];
|
||||
|
||||
Align::new(
|
||||
OnboardingVisual::new(panel_background, pills, false)
|
||||
.with_rects(rects)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::elements::Align;
|
||||
use warpui::Element;
|
||||
|
||||
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
|
||||
|
||||
pub(crate) fn intention_visual(
|
||||
panel_background: ColorU,
|
||||
neutral: ColorU,
|
||||
blue: ColorU,
|
||||
green: ColorU,
|
||||
yellow: ColorU,
|
||||
) -> Box<dyn Element> {
|
||||
// X is in percent of the inner panel width; negative values intentionally protrude.
|
||||
const LEFT_PROTRUSION_PCT: f32 = -0.06;
|
||||
const RIGHT_PROTRUSION_PCT: f32 = 0.03;
|
||||
const BAR_H_PCT: f32 = 0.040;
|
||||
|
||||
let mut pills = Vec::new();
|
||||
|
||||
// Top-left neutral bars.
|
||||
let top_left = [(0.38, 0.18), (0.55, 0.24), (0.42, 0.30), (0.48, 0.36)];
|
||||
pills.extend(top_left.into_iter().map(|(w_pct, y_pct)| Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
|
||||
color: neutral,
|
||||
}));
|
||||
|
||||
// Upper-right blue bars.
|
||||
let blue_bars = [(0.26, 0.12), (0.16, 0.18), (0.34, 0.48), (0.26, 0.54)];
|
||||
pills.extend(blue_bars.into_iter().map(|(w_pct, y_pct)| Pill {
|
||||
rect: RectPct::new(1.0 - w_pct + RIGHT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
|
||||
color: blue,
|
||||
}));
|
||||
|
||||
// Mid section.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.60, 0.70, BAR_H_PCT),
|
||||
color: neutral,
|
||||
});
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.66, 0.52, BAR_H_PCT),
|
||||
color: neutral,
|
||||
});
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.60, 0.32, BAR_H_PCT),
|
||||
color: neutral,
|
||||
});
|
||||
|
||||
// Bottom container + rows.
|
||||
const BOTTOM_Y_PCT: f32 = 0.80;
|
||||
const ROW_GAP_PCT: f32 = 0.012;
|
||||
|
||||
let bottom_w = 1.0 + (-LEFT_PROTRUSION_PCT) + RIGHT_PROTRUSION_PCT;
|
||||
|
||||
let row_x = LEFT_PROTRUSION_PCT + 0.02;
|
||||
let row_w = bottom_w - 0.04;
|
||||
let mut row_y = BOTTOM_Y_PCT + 0.03;
|
||||
|
||||
// Row 1: full green.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(row_x, row_y, row_w, BAR_H_PCT),
|
||||
color: green,
|
||||
});
|
||||
row_y += BAR_H_PCT + ROW_GAP_PCT;
|
||||
|
||||
// Row 2: mostly green.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(row_x, row_y, row_w * 0.82, BAR_H_PCT),
|
||||
color: green,
|
||||
});
|
||||
row_y += BAR_H_PCT + ROW_GAP_PCT;
|
||||
|
||||
// Row 3: yellow.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(row_x, row_y, row_w * 0.90, BAR_H_PCT),
|
||||
color: yellow,
|
||||
});
|
||||
row_y += BAR_H_PCT + ROW_GAP_PCT;
|
||||
|
||||
// Row 4: shorter green.
|
||||
pills.push(Pill {
|
||||
rect: RectPct::new(row_x, row_y, row_w * 0.28, BAR_H_PCT),
|
||||
color: green,
|
||||
});
|
||||
|
||||
Align::new(OnboardingVisual::new(panel_background, pills, true).finish()).finish()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod agent_visual;
|
||||
mod intention_terminal_visual;
|
||||
mod intention_visual;
|
||||
mod onboarding_visual;
|
||||
mod project_visual;
|
||||
mod theme_picker_mock_visual;
|
||||
|
||||
pub(crate) use agent_visual::agent_visual;
|
||||
pub(crate) use intention_terminal_visual::intention_terminal_visual;
|
||||
pub(crate) use intention_visual::intention_visual;
|
||||
pub(crate) use project_visual::project_visual;
|
||||
pub(crate) use theme_picker_mock_visual::theme_picker_visual;
|
||||
@@ -0,0 +1,358 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
|
||||
use warpui::geometry::rect::RectF;
|
||||
use warpui::geometry::vector::{vec2f, Vector2F};
|
||||
use warpui::image_cache::{AnimatedImageBehavior, CacheOption, FitType, Image, ImageCache};
|
||||
use warpui::{
|
||||
elements::{CornerRadius, Fill, Point, Radius},
|
||||
event::DispatchedEvent,
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
|
||||
SingletonEntity as _, SizeConstraint,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct RectPct {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl RectPct {
|
||||
pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
Self { x, y, w, h }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Pill {
|
||||
pub rect: RectPct,
|
||||
pub color: ColorU,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Rect {
|
||||
pub rect: RectPct,
|
||||
pub color: ColorU,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct IconPct {
|
||||
pub icon: Icon,
|
||||
pub color: ColorU,
|
||||
pub center_x: f32,
|
||||
pub center_y: f32,
|
||||
pub width_pct: f32,
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingVisual {
|
||||
panel_background: ColorU,
|
||||
pills: Vec<Pill>,
|
||||
rects: Vec<Rect>,
|
||||
icons: Vec<IconPct>,
|
||||
terminal_box: bool,
|
||||
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
}
|
||||
|
||||
impl OnboardingVisual {
|
||||
// Padding scale parameters (based on max(x, y) in pixels).
|
||||
const MIN_PADDING_SIZE_PX: f32 = 500.0;
|
||||
const MAX_PADDING_SIZE_PX: f32 = 2000.0;
|
||||
const MIN_PADDING_PCT: f32 = 0.10;
|
||||
const MAX_PADDING_PCT: f32 = 0.30;
|
||||
|
||||
pub fn new(panel_background: ColorU, pills: Vec<Pill>, terminal_box: bool) -> Self {
|
||||
Self::new_internal(panel_background, pills, terminal_box)
|
||||
}
|
||||
|
||||
fn new_internal(panel_background: ColorU, pills: Vec<Pill>, terminal_box: bool) -> Self {
|
||||
Self {
|
||||
panel_background,
|
||||
pills,
|
||||
icons: Vec::new(),
|
||||
rects: Vec::new(),
|
||||
terminal_box,
|
||||
size: None,
|
||||
origin: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_icons(mut self, icons: Vec<IconPct>) -> Self {
|
||||
self.icons = icons;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rects(mut self, rects: Vec<Rect>) -> Self {
|
||||
self.rects = rects;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn compute_contained_size(constraint: SizeConstraint) -> Vector2F {
|
||||
// Maintain the old image’s aspect ratio so the right-side layout stays stable.
|
||||
const ASPECT_RATIO: f32 = 1.5;
|
||||
|
||||
let max = constraint.max;
|
||||
|
||||
if max.x().is_infinite() && max.y().is_infinite() {
|
||||
return vec2f(constraint.min.x().max(0.), constraint.min.y().max(0.));
|
||||
}
|
||||
|
||||
let mut width = if max.x().is_finite() {
|
||||
max.x().max(0.)
|
||||
} else {
|
||||
(max.y().max(0.)) * ASPECT_RATIO
|
||||
};
|
||||
|
||||
let mut height = if max.y().is_finite() {
|
||||
max.y().max(0.)
|
||||
} else {
|
||||
width / ASPECT_RATIO
|
||||
};
|
||||
|
||||
// Contain within both axes when both are finite.
|
||||
if max.x().is_finite() && max.y().is_finite() {
|
||||
height = width / ASPECT_RATIO;
|
||||
if height > max.y() {
|
||||
height = max.y();
|
||||
width = height * ASPECT_RATIO;
|
||||
}
|
||||
}
|
||||
|
||||
vec2f(width.max(1.), height.max(1.))
|
||||
}
|
||||
|
||||
fn feature_radius_px(inner_height: f32) -> f32 {
|
||||
// All features use the same radius.
|
||||
inner_height * 0.02
|
||||
}
|
||||
|
||||
fn draw_pill(
|
||||
&self,
|
||||
rect: RectF,
|
||||
color: ColorU,
|
||||
feature_radius_px: f32,
|
||||
ctx: &mut PaintContext,
|
||||
) {
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(rect)
|
||||
.with_background(Fill::Solid(color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(feature_radius_px)));
|
||||
}
|
||||
|
||||
fn draw_rect(&self, rect: RectF, color: ColorU, ctx: &mut PaintContext) {
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(rect)
|
||||
.with_background(Fill::Solid(color));
|
||||
}
|
||||
|
||||
fn rect_from_pct(inner_origin: Vector2F, inner_size: Vector2F, rect: RectPct) -> RectF {
|
||||
RectF::new(
|
||||
vec2f(
|
||||
inner_origin.x() + inner_size.x() * rect.x,
|
||||
inner_origin.y() + inner_size.y() * rect.y,
|
||||
),
|
||||
vec2f(inner_size.x() * rect.w, inner_size.y() * rect.h),
|
||||
)
|
||||
}
|
||||
|
||||
fn icon_rect_from_pct(
|
||||
inner_origin: Vector2F,
|
||||
inner_size: Vector2F,
|
||||
center_x: f32,
|
||||
center_y: f32,
|
||||
width_pct: f32,
|
||||
) -> RectF {
|
||||
let center = vec2f(
|
||||
inner_origin.x() + inner_size.x() * center_x,
|
||||
inner_origin.y() + inner_size.y() * center_y,
|
||||
);
|
||||
let width_px = inner_size.x() * width_pct;
|
||||
RectF::new(
|
||||
center - vec2f(width_px / 2.0, width_px / 2.0),
|
||||
vec2f(width_px, width_px),
|
||||
)
|
||||
}
|
||||
|
||||
fn draw_icon(
|
||||
&self,
|
||||
rect: RectF,
|
||||
icon: Icon,
|
||||
color: ColorU,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let bounds = (rect.size() * ctx.scene.scale_factor()).to_i32();
|
||||
if bounds.x() <= 0 || bounds.y() <= 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let path: &'static str = icon.into();
|
||||
|
||||
let asset_cache = AssetCache::as_ref(app);
|
||||
match ImageCache::as_ref(app).image(
|
||||
AssetSource::Bundled { path },
|
||||
bounds,
|
||||
FitType::Contain,
|
||||
AnimatedImageBehavior::FullAnimation,
|
||||
CacheOption::BySize,
|
||||
ctx.max_texture_dimension_2d,
|
||||
asset_cache,
|
||||
) {
|
||||
AssetState::Loaded { data } => match data.as_ref() {
|
||||
Image::Static(image) => {
|
||||
let logical_image_size = image.size().to_f32() / ctx.scene.scale_factor();
|
||||
let origin = rect.origin() + ((rect.size() - logical_image_size) / 2.0);
|
||||
ctx.scene.draw_icon(
|
||||
RectF::new(origin, logical_image_size),
|
||||
image.clone(),
|
||||
1.0,
|
||||
color,
|
||||
);
|
||||
}
|
||||
Image::Animated(_) => {
|
||||
log::info!("Animated icons are currently not supported");
|
||||
}
|
||||
},
|
||||
AssetState::Loading { handle } => {
|
||||
ctx.repaint_after_load(handle);
|
||||
}
|
||||
AssetState::Evicted => {
|
||||
log::warn!("Unable to render svg because it was evicted");
|
||||
}
|
||||
AssetState::FailedToLoad(err) => {
|
||||
log::warn!("Unable to render svg: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for OnboardingVisual {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
_ctx: &mut LayoutContext,
|
||||
_app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let size = Self::compute_contained_size(constraint);
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, _app: &AppContext) {
|
||||
let Some(size) = self.size else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
|
||||
let max_dimension = size.x().max(size.y());
|
||||
|
||||
// Scale padding linearly with size.
|
||||
let padding_pct = if max_dimension <= Self::MIN_PADDING_SIZE_PX {
|
||||
Self::MIN_PADDING_PCT
|
||||
} else if max_dimension >= Self::MAX_PADDING_SIZE_PX {
|
||||
Self::MAX_PADDING_PCT
|
||||
} else {
|
||||
let t = (max_dimension - Self::MIN_PADDING_SIZE_PX)
|
||||
/ (Self::MAX_PADDING_SIZE_PX - Self::MIN_PADDING_SIZE_PX);
|
||||
Self::MIN_PADDING_PCT + t * (Self::MAX_PADDING_PCT - Self::MIN_PADDING_PCT)
|
||||
};
|
||||
|
||||
let padding_x: f32 = padding_pct * size.x();
|
||||
let padding_y: f32 = padding_pct * size.y();
|
||||
|
||||
let inner_origin = origin + vec2f(padding_x, padding_y);
|
||||
let inner_size = vec2f(
|
||||
(size.x() - 2.0 * padding_x).max(1.),
|
||||
(size.y() - 2.0 * padding_y).max(1.),
|
||||
);
|
||||
|
||||
let feature_radius_px = Self::feature_radius_px(inner_size.y());
|
||||
|
||||
// Background panel.
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(inner_origin, inner_size))
|
||||
.with_background(Fill::Solid(self.panel_background))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(feature_radius_px)));
|
||||
|
||||
if self.terminal_box {
|
||||
// Bottom container behind the final rows.
|
||||
const TERMINAL_BOX_Y_PCT: f32 = 0.80;
|
||||
const TERMINAL_BOX_H_PCT: f32 = 0.25;
|
||||
|
||||
let min_x = self
|
||||
.pills
|
||||
.iter()
|
||||
.map(|pill| pill.rect.x)
|
||||
.fold(0.0_f32, f32::min);
|
||||
let max_x = self
|
||||
.pills
|
||||
.iter()
|
||||
.map(|pill| pill.rect.x + pill.rect.w)
|
||||
.fold(1.0_f32, f32::max);
|
||||
|
||||
let box_rect = Self::rect_from_pct(
|
||||
inner_origin,
|
||||
inner_size,
|
||||
RectPct::new(min_x, TERMINAL_BOX_Y_PCT, max_x - min_x, TERMINAL_BOX_H_PCT),
|
||||
);
|
||||
|
||||
let box_color = self
|
||||
.pills
|
||||
.first()
|
||||
.map(|pill| pill.color)
|
||||
.unwrap_or(self.panel_background);
|
||||
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(box_rect)
|
||||
.with_background(Fill::Solid(box_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(feature_radius_px)));
|
||||
}
|
||||
|
||||
// Rects.
|
||||
for rect in &self.rects {
|
||||
let rect_px = Self::rect_from_pct(inner_origin, inner_size, rect.rect);
|
||||
self.draw_rect(rect_px, rect.color, ctx);
|
||||
}
|
||||
|
||||
// Pills.
|
||||
for pill in &self.pills {
|
||||
let rect = Self::rect_from_pct(inner_origin, inner_size, pill.rect);
|
||||
self.draw_pill(rect, pill.color, feature_radius_px, ctx);
|
||||
}
|
||||
|
||||
// Icons.
|
||||
for icon in &self.icons {
|
||||
let rect = Self::icon_rect_from_pct(
|
||||
inner_origin,
|
||||
inner_size,
|
||||
icon.center_x,
|
||||
icon.center_y,
|
||||
icon.width_pct,
|
||||
);
|
||||
self.draw_icon(rect, icon.icon, icon.color, ctx, _app);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_event: &DispatchedEvent,
|
||||
_ctx: &mut EventContext,
|
||||
_app: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::Align;
|
||||
use warpui::Element;
|
||||
|
||||
use super::onboarding_visual::{IconPct, OnboardingVisual, Pill, RectPct};
|
||||
|
||||
pub(crate) fn project_visual(
|
||||
panel_background: ColorU,
|
||||
pill_color: ColorU,
|
||||
center_icon_color: ColorU,
|
||||
side_icon_color: ColorU,
|
||||
) -> Box<dyn Element> {
|
||||
const PILL_W_PCT: f32 = 0.23;
|
||||
const PILL_H_PCT: f32 = 0.34;
|
||||
|
||||
let x = (1.0 - PILL_W_PCT) / 2.0;
|
||||
let y = (1.0 - PILL_H_PCT) / 2.0;
|
||||
|
||||
let pills = vec![Pill {
|
||||
rect: RectPct::new(x, y, PILL_W_PCT, PILL_H_PCT),
|
||||
color: pill_color,
|
||||
}];
|
||||
|
||||
// Five folder icons, all vertically centered.
|
||||
// - 1 centered in the middle, fitting within the pill.
|
||||
// - 2 on each side, 70% the size of the center icon.
|
||||
// - furthest left/right centers are at 0% and 100%.
|
||||
const CENTER_Y: f32 = 0.5;
|
||||
|
||||
let center_icon_w = PILL_W_PCT * 0.8;
|
||||
let side_icon_w = center_icon_w * 0.7;
|
||||
|
||||
let icons = vec![
|
||||
IconPct {
|
||||
icon: Icon::Folder,
|
||||
color: side_icon_color,
|
||||
center_x: 0.0,
|
||||
center_y: CENTER_Y,
|
||||
width_pct: side_icon_w,
|
||||
},
|
||||
IconPct {
|
||||
icon: Icon::Folder,
|
||||
color: side_icon_color,
|
||||
center_x: 0.25,
|
||||
center_y: CENTER_Y,
|
||||
width_pct: side_icon_w,
|
||||
},
|
||||
IconPct {
|
||||
icon: Icon::Folder,
|
||||
color: center_icon_color,
|
||||
center_x: 0.5,
|
||||
center_y: CENTER_Y,
|
||||
width_pct: center_icon_w,
|
||||
},
|
||||
IconPct {
|
||||
icon: Icon::Folder,
|
||||
color: side_icon_color,
|
||||
center_x: 0.75,
|
||||
center_y: CENTER_Y,
|
||||
width_pct: side_icon_w,
|
||||
},
|
||||
IconPct {
|
||||
icon: Icon::Folder,
|
||||
color: side_icon_color,
|
||||
center_x: 1.0,
|
||||
center_y: CENTER_Y,
|
||||
width_pct: side_icon_w,
|
||||
},
|
||||
];
|
||||
|
||||
Align::new(
|
||||
OnboardingVisual::new(panel_background, pills, false)
|
||||
.with_icons(icons)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
|
||||
use warpui::{elements::Align, Element};
|
||||
|
||||
pub(crate) fn theme_picker_visual(appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let panel_background = internal_colors::neutral_2(theme);
|
||||
let neutral = internal_colors::neutral_4(theme);
|
||||
|
||||
let yellow = theme.ansi_fg_yellow();
|
||||
let red = theme.ansi_fg_red();
|
||||
let blue = theme.ansi_fg_blue();
|
||||
let green = theme.ansi_fg_green();
|
||||
let magenta = theme.ansi_fg_magenta();
|
||||
|
||||
// X is in percent of the inner panel width; negative values intentionally protrude.
|
||||
const LEFT_PROTRUSION_PCT: f32 = -0.06;
|
||||
const BAR_H_PCT: f32 = 0.038;
|
||||
|
||||
let pills = [
|
||||
// Top-left neutral bars.
|
||||
Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.18, 0.30, BAR_H_PCT),
|
||||
color: neutral,
|
||||
},
|
||||
Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.24, 0.50, BAR_H_PCT),
|
||||
color: neutral,
|
||||
},
|
||||
Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.30, 0.36, BAR_H_PCT),
|
||||
color: neutral,
|
||||
},
|
||||
Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.36, 0.42, BAR_H_PCT),
|
||||
color: neutral,
|
||||
},
|
||||
// Upper-right colored bars.
|
||||
Pill {
|
||||
rect: RectPct::new(0.52 + LEFT_PROTRUSION_PCT, 0.24, 0.28, BAR_H_PCT),
|
||||
color: yellow,
|
||||
},
|
||||
Pill {
|
||||
rect: RectPct::new(0.44 + LEFT_PROTRUSION_PCT, 0.36, 0.14, BAR_H_PCT),
|
||||
color: red,
|
||||
},
|
||||
// Mid section.
|
||||
Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.62, 0.68, BAR_H_PCT),
|
||||
color: blue,
|
||||
},
|
||||
Pill {
|
||||
rect: RectPct::new(0.70 + LEFT_PROTRUSION_PCT, 0.62, 0.18, BAR_H_PCT),
|
||||
color: green,
|
||||
},
|
||||
Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.68, 0.40, BAR_H_PCT),
|
||||
color: neutral,
|
||||
},
|
||||
// Bottom section.
|
||||
Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.80, 0.44, BAR_H_PCT),
|
||||
color: neutral,
|
||||
},
|
||||
Pill {
|
||||
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.86, 0.28, BAR_H_PCT),
|
||||
color: neutral,
|
||||
},
|
||||
Pill {
|
||||
rect: RectPct::new(0.30 + LEFT_PROTRUSION_PCT, 0.86, 0.30, BAR_H_PCT),
|
||||
color: magenta,
|
||||
},
|
||||
];
|
||||
|
||||
Align::new(OnboardingVisual::new(panel_background, pills.to_vec(), false).finish()).finish()
|
||||
}
|
||||
Reference in New Issue
Block a user