Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user