Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
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);
}
}
}
}
+217
View File
@@ -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);
}
}
}
}
+380
View File
@@ -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()
}
+28
View File
@@ -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);
}
}
}
}
+308
View File
@@ -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()
}
}