Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
use enum_iterator::{all, Sequence};
|
||||
use itertools::{Either, Itertools};
|
||||
use warpui::elements::CornerRadius;
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::units::Pixels;
|
||||
use warpui::FocusContext;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CrossAxisAlignment, Element, Fill, Flex, MainAxisSize, MouseStateHandle, ParentElement,
|
||||
Radius, Shrinkable,
|
||||
},
|
||||
keymap::{DescriptionContext, Keystroke},
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::settings_view;
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
command_palette::PRIORITIZED_KEYBINDINGS,
|
||||
search_bar::SearchBar,
|
||||
settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier},
|
||||
util::bindings::filter_bindings_including_keystroke,
|
||||
workspace::WorkspaceAction,
|
||||
};
|
||||
use warpui::ModelHandle;
|
||||
|
||||
use crate::{
|
||||
editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
|
||||
TextOptions,
|
||||
},
|
||||
util::bindings::CommandBinding,
|
||||
};
|
||||
|
||||
use super::{
|
||||
section_views::{
|
||||
DESCRIPTION_FONT_SIZE, ITEM_PADDING_BOTTOM, SCROLLBAR_OFFSET, SCROLLBAR_WIDTH,
|
||||
SECTION_HEADER_FONT_SIZE, SECTION_SPACING,
|
||||
},
|
||||
utils::{get_additional_keybindings, FUNDAMENTALS_KEYBINDINGS},
|
||||
};
|
||||
|
||||
use super::utils::{BLOCKS_KEYBINDINGS, INPUT_EDITOR_KEYBINDINGS, TERMINAL_KEYBINDINGS};
|
||||
|
||||
const KEYBINDINGS_PAGE_SHORTCUT: &str = "workspace:toggle_keybindings_page";
|
||||
const LINK_WIDTH: f32 = 30.;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
navigate_to_settings_link: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct KeybindingsView {
|
||||
/// List of all keybidings.
|
||||
bindings: Option<Vec<CommandBinding>>,
|
||||
/// List of keybindings based on search query.
|
||||
binding_results: Option<Vec<CommandBinding>>,
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
search_bar: ViewHandle<SearchBar>,
|
||||
search_editor: ViewHandle<EditorView>,
|
||||
}
|
||||
|
||||
/// Keybindings are sorted into these sections,
|
||||
/// where "Fundamentals" is the default for any remaining non-categorized ones.
|
||||
/// This should always align with documentation: https://docs.warp.dev/getting-started/keyboard-shortcuts
|
||||
#[derive(Clone, Eq, PartialEq, Sequence)]
|
||||
pub enum KeybindingSection {
|
||||
Essentials,
|
||||
Blocks,
|
||||
InputEditor,
|
||||
Terminal,
|
||||
Fundamentals,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum KeybindingsAction {}
|
||||
|
||||
impl KeybindingsView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let bindings = Some(Self::build_bindings(ctx));
|
||||
|
||||
let search_editor = {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let options = SingleLineEditorOptions {
|
||||
text: TextOptions::ui_font_size(appearance),
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
};
|
||||
ctx.add_typed_action_view(|ctx| EditorView::single_line(options, ctx))
|
||||
};
|
||||
|
||||
ctx.subscribe_to_view(&search_editor, move |me, _, event, ctx| {
|
||||
me.handle_search_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
search_editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer_and_reset_undo_stack(ctx);
|
||||
editor.set_placeholder_text(settings_view::keybindings::SEARCH_PLACEHOLDER, ctx);
|
||||
});
|
||||
|
||||
let search_bar = {
|
||||
let style = UiComponentStyles {
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Percentage(20.))),
|
||||
margin: Some(Coords {
|
||||
top: SECTION_SPACING,
|
||||
bottom: SECTION_SPACING,
|
||||
left: SECTION_SPACING + SCROLLBAR_OFFSET,
|
||||
right: SECTION_SPACING + SCROLLBAR_OFFSET,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
ctx.add_typed_action_view(|_| {
|
||||
let mut search_bar = SearchBar::new(search_editor.clone());
|
||||
search_bar.with_style(style);
|
||||
search_bar
|
||||
})
|
||||
};
|
||||
|
||||
let bindings_notifier = KeybindingChangedNotifier::handle(ctx);
|
||||
ctx.subscribe_to_model(&bindings_notifier, |me, _, event, ctx| {
|
||||
me.handle_keybinding_changed(event, ctx);
|
||||
});
|
||||
|
||||
// Rebuild bindings when layout-dependent settings change, so dynamic
|
||||
// descriptions (e.g. "Close tabs below" under vertical tabs) stay in
|
||||
// sync while the panel is open. Other surfaces repopulate every time
|
||||
// they're opened; this one is built once per panel lifetime.
|
||||
let tab_settings_handle = TabSettings::handle(ctx);
|
||||
ctx.observe(&tab_settings_handle, Self::rebuild_bindings);
|
||||
|
||||
Self {
|
||||
bindings: bindings.clone(),
|
||||
binding_results: bindings,
|
||||
clipped_scroll_state: Default::default(),
|
||||
mouse_state_handles: Default::default(),
|
||||
search_bar,
|
||||
search_editor,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bindings(ctx: &AppContext) -> Vec<CommandBinding> {
|
||||
ctx.get_key_bindings()
|
||||
.filter_map(|lens| CommandBinding::from_lens(lens, ctx))
|
||||
.chain(get_additional_keybindings())
|
||||
.filter(|a| {
|
||||
a.trigger.is_some()
|
||||
&& !a
|
||||
.description
|
||||
.in_context(DescriptionContext::Default)
|
||||
.is_empty()
|
||||
})
|
||||
.sorted_by(|a, b| {
|
||||
a.description
|
||||
.in_context(DescriptionContext::Default)
|
||||
.cmp(b.description.in_context(DescriptionContext::Default))
|
||||
})
|
||||
.dedup_by(|a, b| a.description == b.description)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rebuild_bindings(
|
||||
&mut self,
|
||||
_tab_settings: ModelHandle<TabSettings>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let bindings = Self::build_bindings(ctx);
|
||||
// Preserve any active search filter so the user doesn't lose their
|
||||
// query position just because they toggled a layout setting.
|
||||
let search_term = self.search_editor.as_ref(ctx).buffer_text(ctx);
|
||||
let filtered: Vec<CommandBinding> = filter_bindings_including_keystroke(
|
||||
bindings.iter(),
|
||||
&search_term,
|
||||
DescriptionContext::Default,
|
||||
)
|
||||
.map(|(_, binding)| binding.clone())
|
||||
.collect();
|
||||
self.bindings = Some(bindings);
|
||||
self.binding_results = Some(filtered);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_keybinding_changed(
|
||||
&mut self,
|
||||
event: &KeybindingChangedEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
KeybindingChangedEvent::BindingChanged {
|
||||
binding_name,
|
||||
new_trigger,
|
||||
} => {
|
||||
let visible_binding_updated = update_binding_with_new_trigger(
|
||||
&mut self.bindings,
|
||||
binding_name,
|
||||
new_trigger.clone(),
|
||||
) && update_binding_with_new_trigger(
|
||||
&mut self.binding_results,
|
||||
binding_name,
|
||||
new_trigger.clone(),
|
||||
);
|
||||
if visible_binding_updated || binding_name == KEYBINDINGS_PAGE_SHORTCUT {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_search_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Edited(_) => {
|
||||
let search_term = self.search_editor.as_ref(ctx).buffer_text(ctx);
|
||||
self.binding_results = Some(
|
||||
filter_bindings_including_keystroke(
|
||||
self.bindings.iter().flatten(),
|
||||
&search_term,
|
||||
DescriptionContext::Default,
|
||||
)
|
||||
.map(|orig| orig.1.clone())
|
||||
.collect(),
|
||||
);
|
||||
|
||||
self.clipped_scroll_state.scroll_to(Pixels::zero());
|
||||
ctx.notify();
|
||||
}
|
||||
EditorEvent::Escape => {
|
||||
ctx.emit(KeybindingsEvent::Escape);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a list of sorted command bindings belonging to the given section.
|
||||
/// Bindings that aren't already categorized are added to the "Fundamentals" section.
|
||||
fn get_bindings_by_section(
|
||||
&self,
|
||||
section: KeybindingSection,
|
||||
) -> impl Iterator<Item = CommandBinding> {
|
||||
let bindings = self
|
||||
.binding_results
|
||||
.as_ref()
|
||||
.expect("Should have command bindings vector");
|
||||
|
||||
let binding_list = match section {
|
||||
KeybindingSection::Essentials => PRIORITIZED_KEYBINDINGS,
|
||||
KeybindingSection::Blocks => BLOCKS_KEYBINDINGS,
|
||||
KeybindingSection::InputEditor => INPUT_EDITOR_KEYBINDINGS,
|
||||
KeybindingSection::Terminal => TERMINAL_KEYBINDINGS,
|
||||
KeybindingSection::Fundamentals => FUNDAMENTALS_KEYBINDINGS,
|
||||
};
|
||||
|
||||
let filtered_bindings = bindings.iter().filter_map(|binding| {
|
||||
// Return bindings that match those listed in their corresponding section
|
||||
if binding_list.iter().any(|&x| x == binding.name) {
|
||||
Some(binding.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let categorized_bindings = [
|
||||
PRIORITIZED_KEYBINDINGS,
|
||||
BLOCKS_KEYBINDINGS,
|
||||
INPUT_EDITOR_KEYBINDINGS,
|
||||
TERMINAL_KEYBINDINGS,
|
||||
FUNDAMENTALS_KEYBINDINGS,
|
||||
]
|
||||
.concat();
|
||||
|
||||
// Return non-categorized bindings to the "Fundamentals" section
|
||||
let extended_iterator = if section == KeybindingSection::Fundamentals {
|
||||
let remaining_bindings = bindings.iter().filter_map(|binding| {
|
||||
if categorized_bindings.contains(&binding.name.as_str()) {
|
||||
None
|
||||
} else {
|
||||
// Return binding if not found in any categories
|
||||
Some(binding.clone())
|
||||
}
|
||||
});
|
||||
Either::Left(filtered_bindings.chain(remaining_bindings))
|
||||
} else {
|
||||
Either::Right(filtered_bindings)
|
||||
};
|
||||
|
||||
extended_iterator.sorted_by(|a, b| {
|
||||
a.description
|
||||
.in_context(DescriptionContext::Default)
|
||||
.cmp(b.description.in_context(DescriptionContext::Default))
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper function to render wrappable text given different text styles.
|
||||
/// Use override_style to further customize.
|
||||
fn render_text(
|
||||
&self,
|
||||
text: String,
|
||||
override_style: Option<UiComponentStyles>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// Default text style
|
||||
let mut style = UiComponentStyles {
|
||||
font_size: Some(DESCRIPTION_FONT_SIZE),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(override_style) = override_style {
|
||||
style = style.merge(override_style);
|
||||
}
|
||||
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(text, true)
|
||||
.with_style(style)
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_subheader(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let bindings = self
|
||||
.bindings
|
||||
.as_ref()
|
||||
.expect("Should have command bindings vector");
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
// If there is a valid keybinding set that opens this panel, display it
|
||||
// to the user.
|
||||
if let Some(keystroke) = bindings
|
||||
.iter()
|
||||
.find(|&binding| binding.name == KEYBINDINGS_PAGE_SHORTCUT)
|
||||
.and_then(|shortcut| shortcut.trigger.as_ref())
|
||||
{
|
||||
let keybinding_row = Flex::row()
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.keyboard_shortcut(keystroke)
|
||||
.with_style(UiComponentStyles {
|
||||
margin: Some(Coords {
|
||||
left: 0.0,
|
||||
right: SCROLLBAR_OFFSET,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_text("To toggle this panel".into(), None, appearance))
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish();
|
||||
|
||||
column.add_child(
|
||||
Container::new(keybinding_row)
|
||||
.with_padding_bottom(SECTION_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let settings_link = ConstrainedBox::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
"here.".into(),
|
||||
None,
|
||||
Some(Box::new(|ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ConfigureKeybindingSettings {
|
||||
keybinding_name: None,
|
||||
});
|
||||
})),
|
||||
self.mouse_state_handles.navigate_to_settings_link.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(DESCRIPTION_FONT_SIZE),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_max_width(LINK_WIDTH)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
column
|
||||
.with_child(self.render_text(
|
||||
"Go to settings > keyboard shortcuts to configure custom keybindings".into(),
|
||||
None,
|
||||
appearance,
|
||||
))
|
||||
.with_child(settings_link)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(SECTION_SPACING)
|
||||
.with_margin_bottom(SECTION_SPACING)
|
||||
.with_margin_left(SCROLLBAR_OFFSET)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Returns a list of rendered bindings within the given section, else None if section is empty.
|
||||
fn render_section(
|
||||
&self,
|
||||
section: KeybindingSection,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let mut bindings = self.get_bindings_by_section(section.clone()).peekable();
|
||||
|
||||
// Don't render section if there are no bindings to show.
|
||||
bindings.peek()?;
|
||||
|
||||
let mut binding_list =
|
||||
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
let title = match section {
|
||||
KeybindingSection::Essentials => "Essentials",
|
||||
KeybindingSection::Blocks => "Blocks",
|
||||
KeybindingSection::InputEditor => "Input Editor",
|
||||
KeybindingSection::Terminal => "Terminal",
|
||||
KeybindingSection::Fundamentals => "Fundamentals",
|
||||
};
|
||||
|
||||
let mut section_header = self.render_text(
|
||||
title.into(),
|
||||
Some(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().active_ui_text_color().into()),
|
||||
font_size: Some(SECTION_HEADER_FONT_SIZE),
|
||||
..Default::default()
|
||||
}),
|
||||
appearance,
|
||||
);
|
||||
|
||||
section_header = Container::new(section_header)
|
||||
.with_margin_bottom(ITEM_PADDING_BOTTOM)
|
||||
.with_uniform_padding(SECTION_SPACING)
|
||||
.with_padding_left(SECTION_SPACING + SCROLLBAR_OFFSET)
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_border(
|
||||
Border::top(1.)
|
||||
.with_border_color(appearance.theme().split_pane_border_color().into()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
binding_list.add_child(section_header);
|
||||
|
||||
for binding in bindings {
|
||||
let mut binding_row = Flex::row();
|
||||
|
||||
let label = self.render_text(
|
||||
binding
|
||||
.description
|
||||
.in_context(DescriptionContext::Default)
|
||||
.to_string(),
|
||||
None,
|
||||
appearance,
|
||||
);
|
||||
binding_row.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Align::new(Container::new(label).finish()).left().finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
if let Some(trigger) = binding.trigger.clone() {
|
||||
let shortcut = appearance.ui_builder().keyboard_shortcut(&trigger).build();
|
||||
binding_row.add_child(Container::new(shortcut.finish()).finish());
|
||||
}
|
||||
|
||||
let mut binding_row = Container::new(
|
||||
binding_row
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
);
|
||||
binding_row = binding_row
|
||||
.with_uniform_margin(SECTION_SPACING)
|
||||
.with_margin_left(SECTION_SPACING + SCROLLBAR_OFFSET);
|
||||
|
||||
binding_list.add_child(binding_row.finish())
|
||||
}
|
||||
|
||||
Some(binding_list.finish())
|
||||
}
|
||||
|
||||
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let keybinding_sections = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_children(
|
||||
all::<KeybindingSection>()
|
||||
.filter_map(|section| self.render_section(section, appearance))
|
||||
.map(|child| {
|
||||
Container::new(child)
|
||||
.with_margin_bottom(SECTION_SPACING)
|
||||
.finish()
|
||||
}),
|
||||
);
|
||||
|
||||
ClippedScrollable::vertical(
|
||||
self.clipped_scroll_state.clone(),
|
||||
keybinding_sections.finish(),
|
||||
SCROLLBAR_WIDTH,
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
Fill::None,
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum KeybindingsEvent {
|
||||
Escape,
|
||||
}
|
||||
|
||||
impl Entity for KeybindingsView {
|
||||
type Event = KeybindingsEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for KeybindingsView {
|
||||
type Action = KeybindingsAction;
|
||||
}
|
||||
|
||||
impl View for KeybindingsView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ResourceCenterKeybindings"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
ctx.focus(&self.search_editor);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let search_bar = ChildView::new(&self.search_bar).finish();
|
||||
let subheader = self.render_subheader(appearance);
|
||||
let body = self.render_body(appearance);
|
||||
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(search_bar)
|
||||
.with_child(subheader)
|
||||
.with_child(Shrinkable::new(1., body).finish())
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_binding_with_new_trigger(
|
||||
bindings: &mut Option<Vec<CommandBinding>>,
|
||||
name: &str,
|
||||
trigger: Option<Keystroke>,
|
||||
) -> bool {
|
||||
let bindings = match bindings {
|
||||
None => return false,
|
||||
Some(bindings) => bindings,
|
||||
};
|
||||
|
||||
match bindings.iter_mut().find(|binding| binding.name == name) {
|
||||
Some(binding) => {
|
||||
binding.trigger = trigger;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
use crate::{
|
||||
auth::AuthStateProvider,
|
||||
changelog_model::ChangelogModel,
|
||||
channel::ChannelState,
|
||||
features::FeatureFlag,
|
||||
resource_center::skip_tips_and_write_to_user_defaults,
|
||||
send_telemetry_from_ctx,
|
||||
server::telemetry::TelemetryEvent,
|
||||
settings::Settings,
|
||||
themes::theme::{Blend, Fill as FillTheme},
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ClippedScrollStateHandle, ClippedScrollable, Container, CornerRadius, Element,
|
||||
Empty, Fill, Flex, Hoverable, Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
ParentElement, Radius, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::{appearance::Appearance, workspace::WorkspaceAction};
|
||||
|
||||
use super::{
|
||||
section_views::{
|
||||
feature_section::FeatureSectionEvent, SectionViewHandle, BUTTON_PADDING, DETAIL_FONT_SIZE,
|
||||
FOOTER_ICON_SIZE, SCROLLBAR_OFFSET, SCROLLBAR_WIDTH, SECTION_SPACING,
|
||||
SECTION_SPACING_BOTTOM,
|
||||
},
|
||||
sections::sections,
|
||||
ChangelogSectionView, ContentSectionData, ContentSectionView, FeatureSection,
|
||||
FeatureSectionData, FeatureSectionView, Section, TipsCompleted,
|
||||
};
|
||||
|
||||
const SEND_SVG_PATH: &str = "bundled/svg/send.svg";
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
copy_version: MouseStateHandle,
|
||||
invite_people: MouseStateHandle,
|
||||
skip_tips: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub enum ResourceCenterMainEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct ResourceCenterMainView {
|
||||
button_mouse_states: MouseStateHandles,
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
section_views: Vec<SectionViewHandle>,
|
||||
tips_completed: ModelHandle<TipsCompleted>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResourceCenterMainAction {
|
||||
Close,
|
||||
SkipTips,
|
||||
}
|
||||
|
||||
impl ResourceCenterMainView {
|
||||
pub fn new(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
tips_completed: ModelHandle<TipsCompleted>,
|
||||
changelog_model_handle: ModelHandle<ChangelogModel>,
|
||||
) -> Self {
|
||||
let action_target = ctx.add_model(|_| ActionTarget::None);
|
||||
let section_views = Self::initialize_section_views(
|
||||
tips_completed.clone(),
|
||||
action_target.clone(),
|
||||
ctx,
|
||||
changelog_model_handle.clone(),
|
||||
);
|
||||
Self {
|
||||
button_mouse_states: Default::default(),
|
||||
clipped_scroll_state: Default::default(),
|
||||
section_views,
|
||||
tips_completed,
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_section_views(
|
||||
tips_completed: ModelHandle<TipsCompleted>,
|
||||
action_target: ModelHandle<ActionTarget>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
changelog_model_handle: ModelHandle<ChangelogModel>,
|
||||
) -> Vec<SectionViewHandle> {
|
||||
let sections = sections(ctx);
|
||||
|
||||
// Set gamified tips count
|
||||
let gamified_tips_count = sections
|
||||
.iter()
|
||||
.map(|section| {
|
||||
let mut count = 0;
|
||||
if let Section::Feature(data) = section {
|
||||
count = data.items.len()
|
||||
}
|
||||
count
|
||||
})
|
||||
.sum();
|
||||
|
||||
tips_completed.update(ctx, |tips_completed, _ctx| {
|
||||
tips_completed.set_gamified_tips_count(gamified_tips_count);
|
||||
});
|
||||
|
||||
// Determines if user has completed all tips under Getting Started
|
||||
let is_onboarded = sections.iter().any(|section| {
|
||||
if let Section::Feature(data) = section {
|
||||
let is_section_completed = data.is_section_completed(tips_completed.as_ref(ctx));
|
||||
is_section_completed && data.section_name == FeatureSection::GettingStarted
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
sections
|
||||
.iter()
|
||||
.map(|section| match section {
|
||||
Section::Feature(data) => {
|
||||
let is_tips_completed = tips_completed.as_ref(ctx).skipped_or_completed;
|
||||
let is_expanded = match data.section_name {
|
||||
// Always show What's New section
|
||||
FeatureSection::WhatsNew => true,
|
||||
FeatureSection::GettingStarted => match ChannelState::app_version() {
|
||||
Some(version) => {
|
||||
match Settings::has_changelog_been_shown(version, ctx) {
|
||||
true => !is_tips_completed && !is_onboarded,
|
||||
false => false,
|
||||
}
|
||||
}
|
||||
None => !is_tips_completed && !is_onboarded,
|
||||
},
|
||||
// Expand Maximize Warp section once user has completed welcome tips,
|
||||
// and keep open after users have completed/skipped all tips
|
||||
FeatureSection::MaximizeWarp => match ChannelState::app_version() {
|
||||
Some(version) => {
|
||||
match Settings::has_changelog_been_shown(version, ctx) {
|
||||
true => is_tips_completed || is_onboarded,
|
||||
false => false,
|
||||
}
|
||||
}
|
||||
None => is_tips_completed || is_onboarded,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// Show tips progress for every section except changelog
|
||||
let show_tips_progress = !matches!(data.section_name, FeatureSection::WhatsNew);
|
||||
|
||||
SectionViewHandle::Feature(Self::build_feature_section_view(
|
||||
data,
|
||||
action_target.clone(),
|
||||
ctx,
|
||||
tips_completed.clone(),
|
||||
show_tips_progress,
|
||||
is_expanded,
|
||||
))
|
||||
}
|
||||
Section::Content(data) => {
|
||||
SectionViewHandle::Content(Self::build_content_section_view(data, ctx))
|
||||
}
|
||||
Section::Changelog() => SectionViewHandle::Changelog(
|
||||
Self::build_changelog_section_view(changelog_model_handle.clone(), ctx),
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_feature_section_view(
|
||||
section_data: &FeatureSectionData,
|
||||
action_target: ModelHandle<ActionTarget>,
|
||||
ctx: &mut ViewContext<ResourceCenterMainView>,
|
||||
tips_completed: ModelHandle<TipsCompleted>,
|
||||
show_tips_progress: bool,
|
||||
is_expanded: bool,
|
||||
) -> ViewHandle<FeatureSectionView> {
|
||||
let feature_section_view = ctx.add_typed_action_view(|ctx| {
|
||||
FeatureSectionView::new(
|
||||
section_data.clone(),
|
||||
action_target,
|
||||
ctx,
|
||||
tips_completed.clone(),
|
||||
show_tips_progress,
|
||||
is_expanded,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&feature_section_view, move |me, _, event, ctx| {
|
||||
me.handle_feature_section_event(event, ctx);
|
||||
});
|
||||
|
||||
feature_section_view
|
||||
}
|
||||
|
||||
fn handle_feature_section_event(
|
||||
&mut self,
|
||||
event: &FeatureSectionEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
FeatureSectionEvent::CloseResourceCenter => {
|
||||
ctx.emit(ResourceCenterMainEvent::Close);
|
||||
ctx.notify();
|
||||
}
|
||||
FeatureSectionEvent::ExpandSection(section_name) => {
|
||||
for section_view in &self.section_views {
|
||||
match section_view {
|
||||
SectionViewHandle::Feature(feature_view_handle) => {
|
||||
if feature_view_handle
|
||||
.as_ref(ctx)
|
||||
.feature_section_data
|
||||
.section_name
|
||||
== *section_name
|
||||
{
|
||||
feature_view_handle.update(ctx, |view, ctx| {
|
||||
view.expand_section(ctx);
|
||||
})
|
||||
}
|
||||
}
|
||||
SectionViewHandle::Content(_) => {}
|
||||
SectionViewHandle::Changelog(_) => {}
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_content_section_view(
|
||||
section_data: &ContentSectionData,
|
||||
ctx: &mut ViewContext<ResourceCenterMainView>,
|
||||
) -> ViewHandle<ContentSectionView> {
|
||||
ctx.add_typed_action_view(|ctx| ContentSectionView::new(section_data.clone(), false, ctx))
|
||||
}
|
||||
|
||||
fn build_changelog_section_view(
|
||||
changelog_model_handle: ModelHandle<ChangelogModel>,
|
||||
ctx: &mut ViewContext<ResourceCenterMainView>,
|
||||
) -> ViewHandle<ChangelogSectionView> {
|
||||
let showing_new_changelog = match ChannelState::app_version() {
|
||||
Some(version) => !Settings::has_changelog_been_shown(version, ctx),
|
||||
None => false,
|
||||
};
|
||||
|
||||
ctx.add_typed_action_view(|ctx: &mut ViewContext<_>| {
|
||||
ChangelogSectionView::new(changelog_model_handle, showing_new_changelog, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_action_target(
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
input_id: Option<EntityId>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
for section_view in &self.section_views {
|
||||
match section_view {
|
||||
SectionViewHandle::Feature(view_handle) => {
|
||||
view_handle.update(ctx, |feature_section_view, ctx| {
|
||||
feature_section_view.set_action_target(window_id, input_id, ctx)
|
||||
});
|
||||
}
|
||||
SectionViewHandle::Content(_) => {}
|
||||
SectionViewHandle::Changelog(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let mut body = Flex::column();
|
||||
|
||||
for section_view in &self.section_views {
|
||||
match section_view {
|
||||
SectionViewHandle::Feature(feature_view_handle) => {
|
||||
body.add_child(ChildView::new(feature_view_handle).finish());
|
||||
}
|
||||
SectionViewHandle::Content(section_view_handle) => {
|
||||
body.add_child(ChildView::new(section_view_handle).finish());
|
||||
}
|
||||
SectionViewHandle::Changelog(section_view_handle) => {
|
||||
body.add_child(ChildView::new(section_view_handle).finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let theme = appearance.theme();
|
||||
|
||||
ClippedScrollable::vertical(
|
||||
self.clipped_scroll_state.clone(),
|
||||
body.finish(),
|
||||
SCROLLBAR_WIDTH,
|
||||
theme.disabled_text_color(theme.background()).into(),
|
||||
theme.main_text_color(theme.background()).into(),
|
||||
Fill::None,
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_current_version(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
// Use a dummy string for git release tag which is not available on local env
|
||||
let version = ChannelState::app_version().unwrap_or("v0.local.testing.string_00");
|
||||
|
||||
let style = UiComponentStyles {
|
||||
font_color: Some(appearance.theme().nonactive_ui_text_color().into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let text = appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(version, true)
|
||||
.with_style(style)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let copy_icon = appearance
|
||||
.ui_builder()
|
||||
.copy_button(
|
||||
FOOTER_ICON_SIZE,
|
||||
self.button_mouse_states.copy_version.clone(),
|
||||
)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::CopyVersion(version))
|
||||
})
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_child(Shrinkable::new(1., Align::new(text).left().finish()).finish())
|
||||
.with_child(Shrinkable::new(0.2, Align::new(copy_icon).finish()).finish())
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(SECTION_SPACING)
|
||||
.with_margin_bottom(BUTTON_PADDING)
|
||||
.with_uniform_padding(BUTTON_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_invite_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let default_styles = UiComponentStyles {
|
||||
font_size: Some(DETAIL_FONT_SIZE),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(appearance.theme().accent().into()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Percentage(20.))),
|
||||
border_width: Some(1.),
|
||||
border_color: Some(appearance.theme().accent().into()),
|
||||
padding: Some(Coords {
|
||||
top: BUTTON_PADDING,
|
||||
bottom: BUTTON_PADDING,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hovered_styles = UiComponentStyles {
|
||||
background: Some(appearance.theme().accent().into()),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().accent())
|
||||
.into_solid(),
|
||||
),
|
||||
..default_styles
|
||||
};
|
||||
|
||||
let clicked_color = appearance.theme().accent().blend(
|
||||
&FillTheme::black().with_opacity(*appearance.theme().details().button_click_opacity()),
|
||||
);
|
||||
let clicked_styles = UiComponentStyles {
|
||||
background: Some(clicked_color.into()),
|
||||
border_color: Some(clicked_color.into()),
|
||||
..hovered_styles
|
||||
};
|
||||
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button_with_custom_styles(
|
||||
ButtonVariant::Outlined,
|
||||
self.button_mouse_states.invite_people.clone(),
|
||||
default_styles,
|
||||
Some(hovered_styles),
|
||||
Some(clicked_styles),
|
||||
None,
|
||||
)
|
||||
.with_text_and_icon_label(
|
||||
TextAndIcon::new(
|
||||
TextAndIconAlignment::IconFirst,
|
||||
"Invite a friend to Warp",
|
||||
Icon::new(SEND_SVG_PATH, appearance.theme().accent()),
|
||||
MainAxisSize::Max,
|
||||
MainAxisAlignment::Center,
|
||||
vec2f(FOOTER_ICON_SIZE, FOOTER_ICON_SIZE),
|
||||
)
|
||||
.with_inner_padding(BUTTON_PADDING),
|
||||
)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowReferralSettingsPage)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(SECTION_SPACING)
|
||||
.with_margin_bottom(SECTION_SPACING_BOTTOM)
|
||||
.with_margin_left(SECTION_SPACING + SCROLLBAR_OFFSET)
|
||||
.with_margin_right(SECTION_SPACING + SCROLLBAR_OFFSET)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_skip_tips_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Align::new(
|
||||
Hoverable::new(self.button_mouse_states.skip_tips.clone(), |state| {
|
||||
let text_color = if state.is_hovered() {
|
||||
appearance.theme().active_ui_text_color().into_solid()
|
||||
} else {
|
||||
appearance.theme().nonactive_ui_text_color().into_solid()
|
||||
};
|
||||
|
||||
let style = UiComponentStyles {
|
||||
font_size: Some(DETAIL_FONT_SIZE),
|
||||
font_color: Some(text_color),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text("Mark all as read", false)
|
||||
.with_style(style)
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ResourceCenterMainAction::SkipTips)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(SECTION_SPACING)
|
||||
.with_margin_right(SCROLLBAR_OFFSET + SECTION_SPACING)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A model for tracking where the events from the resource center view should be dispatched
|
||||
///
|
||||
/// Similar to command palette - we need a model to cache the information of where
|
||||
/// we should send the actions from the resouce center features. When the resource center is opened,
|
||||
/// we cache the current active window ID as well as the input ID of the active
|
||||
/// tab/pane. By sending all the actions to the input view, we ensure that
|
||||
/// they propgate correctly. This propogation assumes that each feature action
|
||||
/// must be in the reponder chain. If an action is not in the responder chain
|
||||
/// (such as a block navigation action) then it won't propogate correctly.
|
||||
pub enum ActionTarget {
|
||||
None,
|
||||
View {
|
||||
window_id: WindowId,
|
||||
input_id: Option<EntityId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for ActionTarget {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl Entity for ResourceCenterMainView {
|
||||
type Event = ResourceCenterMainEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for ResourceCenterMainView {
|
||||
type Action = ResourceCenterMainAction;
|
||||
|
||||
fn handle_action(&mut self, action: &ResourceCenterMainAction, ctx: &mut ViewContext<Self>) {
|
||||
use ResourceCenterMainAction::*;
|
||||
match action {
|
||||
Close => {
|
||||
ctx.notify();
|
||||
ctx.emit(ResourceCenterMainEvent::Close);
|
||||
}
|
||||
SkipTips => {
|
||||
send_telemetry_from_ctx!(TelemetryEvent::ResourceCenterTipsSkipped, ctx);
|
||||
self.tips_completed.update(ctx, |tips_completed, ctx| {
|
||||
skip_tips_and_write_to_user_defaults(tips_completed, ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for ResourceCenterMainView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ResourceCenterMain"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let body = self.render_body(appearance);
|
||||
let invite_button = self.render_invite_button(appearance);
|
||||
let skip_tips = self.render_skip_tips_button(appearance);
|
||||
|
||||
let mut main_page = Flex::column();
|
||||
|
||||
if !AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out()
|
||||
&& !FeatureFlag::AvatarInTabBar.is_enabled()
|
||||
{
|
||||
main_page = main_page.with_child(invite_button);
|
||||
}
|
||||
|
||||
if !self.tips_completed.as_ref(app).skipped_or_completed
|
||||
&& !FeatureFlag::AvatarInTabBar.is_enabled()
|
||||
{
|
||||
main_page.add_child(skip_tips);
|
||||
}
|
||||
|
||||
main_page = main_page
|
||||
.with_child(Shrinkable::new(20., body).finish())
|
||||
.with_child(Shrinkable::new(0.1, Empty::new().finish()).finish()); // placeholder to ensure pane extends to bottom of the window
|
||||
|
||||
if FeatureFlag::Autoupdate.is_enabled() && ChannelState::show_autoupdate_menu_items() {
|
||||
let current_version = self.render_current_version(appearance);
|
||||
main_page.add_child(current_version);
|
||||
}
|
||||
|
||||
main_page.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use settings::Setting as _;
|
||||
|
||||
use crate::{
|
||||
report_if_error, terminal::general_settings::GeneralSettings,
|
||||
util::bindings::trigger_to_keystroke,
|
||||
};
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
mod main_page;
|
||||
pub mod utils;
|
||||
pub use main_page::{ResourceCenterMainEvent, ResourceCenterMainView};
|
||||
mod keybindings_page;
|
||||
pub use keybindings_page::KeybindingsView;
|
||||
mod section_views;
|
||||
pub use section_views::{ChangelogSectionView, ContentSectionView, FeatureSectionView};
|
||||
pub mod sections;
|
||||
mod view;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use view::{ResourceCenterAction, ResourceCenterEvent, ResourceCenterPage, ResourceCenterView};
|
||||
use warpui::{keymap::Keystroke, AppContext, Entity, SingletonEntity};
|
||||
|
||||
use self::section_views::feature_section::FeatureSection;
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Hash,
|
||||
PartialEq,
|
||||
std::cmp::Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "A welcome tip shown to new users.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum Tip {
|
||||
#[schemars(description = "A non-interactive informational hint.")]
|
||||
Hint(TipHint),
|
||||
#[schemars(description = "An interactive tip that triggers an action when clicked.")]
|
||||
Action(TipAction),
|
||||
}
|
||||
|
||||
// Tips that aren't clickable to dispatch an action
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Hash,
|
||||
PartialEq,
|
||||
std::cmp::Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "A non-interactive tip hint.", rename_all = "snake_case")]
|
||||
pub enum TipHint {
|
||||
CreateBlock,
|
||||
BlockSelect,
|
||||
BlockAction,
|
||||
}
|
||||
|
||||
// Tips that are clickable and dispatch an action
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Hash,
|
||||
PartialEq,
|
||||
std::cmp::Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "An interactive tip action.", rename_all = "snake_case")]
|
||||
pub enum TipAction {
|
||||
CommandPalette,
|
||||
SplitPane,
|
||||
ThemePicker,
|
||||
HistorySearch,
|
||||
CommandSearch,
|
||||
AiCommandSearch,
|
||||
SaveNewLaunchConfig,
|
||||
WarpAI,
|
||||
// This toggles Warp Drive rather than opening it. This enum can't directly be
|
||||
// renamed because we serialize it into the welcome tips.
|
||||
OpenWarpDrive,
|
||||
Changelog,
|
||||
// Note that this item has been deprecated from the UI and is not in any section.
|
||||
// We are leaving it in this enum to ensure that we don't re-use `Workflows` as a
|
||||
// value. Since old clients will have this value in their user defaults, we want
|
||||
// to prevent future usage of this enum value.
|
||||
Workflows,
|
||||
}
|
||||
|
||||
impl TipAction {
|
||||
pub fn editable_binding_name(&self) -> &'static str {
|
||||
match self {
|
||||
TipAction::CommandPalette => "workspace:toggle_command_palette",
|
||||
TipAction::SplitPane => "pane_group:add_right",
|
||||
TipAction::HistorySearch => "input:search_command_history",
|
||||
TipAction::CommandSearch => "workspace:show_command_search",
|
||||
TipAction::AiCommandSearch => "input:toggle_natural_language_command_search",
|
||||
TipAction::ThemePicker => "workspace:show_theme_chooser",
|
||||
TipAction::SaveNewLaunchConfig => "workspace:open_launch_config_save_modal",
|
||||
TipAction::WarpAI => "workspace:toggle_ai_assistant",
|
||||
TipAction::OpenWarpDrive => "workspace:toggle_left_panel",
|
||||
// Slash commands are also registered as editable bindings, so callers can look them up here
|
||||
// the same way they do regular app actions.
|
||||
TipAction::Changelog => "/changelog",
|
||||
TipAction::Workflows => "input:toggle_workflows",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyboard_shortcut(&self, ctx: &mut AppContext) -> Option<Keystroke> {
|
||||
ctx.editable_bindings()
|
||||
.find(|binding| binding.name == self.editable_binding_name())
|
||||
.and_then(|binding| trigger_to_keystroke(binding.trigger))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
// Section item that dispatches an action within the app
|
||||
pub struct FeatureItem {
|
||||
pub title: &'static str,
|
||||
pub description: &'static str,
|
||||
pub feature: Tip,
|
||||
pub editable_binding_name: Option<&'static str>,
|
||||
pub shortcut: Option<Keystroke>,
|
||||
}
|
||||
|
||||
impl FeatureItem {
|
||||
pub fn new(
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
feature: Tip,
|
||||
ctx: &mut AppContext,
|
||||
) -> Self {
|
||||
let editable_binding_name;
|
||||
let shortcut;
|
||||
|
||||
match feature {
|
||||
Tip::Hint(_) => {
|
||||
editable_binding_name = None;
|
||||
shortcut = None;
|
||||
}
|
||||
Tip::Action(tip) => {
|
||||
editable_binding_name = Some(tip.editable_binding_name());
|
||||
shortcut = tip.keyboard_shortcut(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
title,
|
||||
description,
|
||||
feature,
|
||||
editable_binding_name,
|
||||
shortcut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
// Section item that links to an external URL
|
||||
pub struct ContentItem {
|
||||
pub title: &'static str,
|
||||
pub description: &'static str,
|
||||
pub url: &'static str,
|
||||
pub button_label: &'static str,
|
||||
}
|
||||
|
||||
pub enum Section {
|
||||
Feature(FeatureSectionData),
|
||||
Content(ContentSectionData),
|
||||
Changelog(),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FeatureSectionData {
|
||||
pub section_name: FeatureSection,
|
||||
pub items: Vec<FeatureItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ContentSectionData {
|
||||
pub section_name: FeatureSection,
|
||||
pub items: Vec<ContentItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChangelogSectionData {
|
||||
pub section_name: FeatureSection,
|
||||
pub date: DateTime<FixedOffset>,
|
||||
pub new_features_markdown: String,
|
||||
pub improvements_markdown: String,
|
||||
pub coming_soon_markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TipsCompleted {
|
||||
pub features_used: HashSet<Tip>,
|
||||
pub skipped_or_completed: bool,
|
||||
pub gamified_tips_count: Option<usize>,
|
||||
}
|
||||
|
||||
impl Entity for TipsCompleted {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl FeatureSectionData {
|
||||
pub fn is_section_completed(&self, tips_completed: &TipsCompleted) -> bool {
|
||||
self.items
|
||||
.iter()
|
||||
.all(|item| tips_completed.features_used.contains(&item.feature))
|
||||
}
|
||||
|
||||
pub fn tips_completed_count(&self, tips_completed: &TipsCompleted) -> usize {
|
||||
self.items
|
||||
.iter()
|
||||
.filter(|item| tips_completed.features_used.contains(&item.feature))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the welcome tip as used, writes their current state to a cloud synced preference.
|
||||
pub fn mark_feature_used_and_write_to_user_defaults(
|
||||
feature: Tip,
|
||||
tips_completed: &mut TipsCompleted,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
if tips_completed.mark_feature_used(feature) {
|
||||
GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| {
|
||||
report_if_error!(general_settings
|
||||
.welcome_tips_features_used
|
||||
.set_value(tips_completed.features_used.clone(), ctx));
|
||||
|
||||
if tips_completed.skipped_or_completed {
|
||||
report_if_error!(general_settings
|
||||
.welcome_tips_skipped_or_completed
|
||||
.set_value(true, ctx));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the model to reflect welcome tips are skipped, writes to user defaults, and sends telemetry.
|
||||
pub fn skip_tips_and_write_to_user_defaults(
|
||||
tips_completed: &mut TipsCompleted,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
tips_completed.skipped_or_completed = true;
|
||||
GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| {
|
||||
report_if_error!(general_settings
|
||||
.welcome_tips_skipped_or_completed
|
||||
.set_value(true, ctx));
|
||||
});
|
||||
}
|
||||
|
||||
/// Updates the model to reflect welcome tips are skipped, writes to user defaults, and sends telemetry.
|
||||
pub fn complete_tips_and_write_to_user_defaults(
|
||||
tips_completed: &mut TipsCompleted,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
tips_completed.skipped_or_completed = true;
|
||||
GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| {
|
||||
report_if_error!(general_settings
|
||||
.welcome_tips_skipped_or_completed
|
||||
.set_value(true, ctx));
|
||||
});
|
||||
}
|
||||
|
||||
impl TipsCompleted {
|
||||
pub fn new(features_used: HashSet<Tip>, skipped_or_completed: bool) -> Self {
|
||||
Self {
|
||||
features_used,
|
||||
skipped_or_completed,
|
||||
gamified_tips_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the feature previously wasn't used.
|
||||
pub fn mark_feature_used(&mut self, feature: Tip) -> bool {
|
||||
let is_new_value = self.features_used.insert(feature);
|
||||
|
||||
// Check if all gamified tips are completed
|
||||
if let Some(total_tips) = self.gamified_tips_count {
|
||||
if is_new_value && self.features_used.len() == total_tips {
|
||||
self.skipped_or_completed = true;
|
||||
}
|
||||
}
|
||||
|
||||
is_new_value
|
||||
}
|
||||
|
||||
pub fn serialized_tips(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(&self.features_used)
|
||||
}
|
||||
|
||||
pub fn completed_count(&self) -> usize {
|
||||
self.features_used.len()
|
||||
}
|
||||
|
||||
pub fn set_gamified_tips_count(&mut self, total: usize) {
|
||||
self.gamified_tips_count = Some(total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
use instant::Instant;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, CacheOption, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element,
|
||||
Flex, FormattedTextElement, HighlightedHyperlink, Icon, Image, MouseStateHandle,
|
||||
ParentElement, Radius,
|
||||
},
|
||||
fonts::Weight,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Entity, ModelAsRef, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
changelog_model::{ChangelogHeader, ChangelogModel, ChangelogState, Event as ChangelogEvent},
|
||||
themes::theme::Fill,
|
||||
ui_components::icons,
|
||||
};
|
||||
use crate::{send_telemetry_from_ctx, server::telemetry::TelemetryEvent};
|
||||
|
||||
use super::{feature_section::FeatureSection, SectionAction, SectionView};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ChangelogMouseStateHandles {
|
||||
top_bar_mouse_state: MouseStateHandle,
|
||||
view_changelogs_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
const CHANGELOG_FETCH_ERROR_MSG: &str = "Unable to fetch the latest changelog.";
|
||||
const CHANGELOG_LOADING_MSG: &str = "Loading...";
|
||||
|
||||
pub struct ChangelogSectionView {
|
||||
changelog_model_handle: ModelHandle<ChangelogModel>,
|
||||
changelog_button_mouse_states: ChangelogMouseStateHandles,
|
||||
is_expanded: bool,
|
||||
// If showing changelog after app update, show special "New features" header to draw attention
|
||||
show_special_new_features_header: bool,
|
||||
new_features_highlighted_link: HighlightedHyperlink,
|
||||
improvements_highlighted_link: HighlightedHyperlink,
|
||||
bug_fixes_highlighted_link: HighlightedHyperlink,
|
||||
changelog_fetch_error: FormattedText,
|
||||
changelog_loading: FormattedText,
|
||||
}
|
||||
|
||||
impl Entity for ChangelogSectionView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TypedActionView for ChangelogSectionView {
|
||||
type Action = SectionAction;
|
||||
|
||||
fn handle_action(&mut self, action: &SectionAction, ctx: &mut ViewContext<Self>) {
|
||||
use SectionAction::*;
|
||||
match action {
|
||||
OpenUrl(url) => {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::OpenChangelogLink { url: url.clone() },
|
||||
ctx
|
||||
);
|
||||
ctx.open_url(url.as_str());
|
||||
}
|
||||
ToggleExpanded => self.toggle_expanded(ctx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_formatted_text_from_string(message: String) -> FormattedText {
|
||||
FormattedText {
|
||||
lines: vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(message),
|
||||
])]
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ChangelogSectionView {
|
||||
pub fn new(
|
||||
changelog_model_handle: ModelHandle<ChangelogModel>,
|
||||
showing_new_changelog: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&changelog_model_handle, |me, _, event, ctx| {
|
||||
me.handle_changelog_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
changelog_model_handle,
|
||||
changelog_button_mouse_states: Default::default(),
|
||||
is_expanded: showing_new_changelog,
|
||||
show_special_new_features_header: showing_new_changelog,
|
||||
new_features_highlighted_link: Default::default(),
|
||||
improvements_highlighted_link: Default::default(),
|
||||
bug_fixes_highlighted_link: Default::default(),
|
||||
changelog_fetch_error: create_formatted_text_from_string(
|
||||
CHANGELOG_FETCH_ERROR_MSG.to_string(),
|
||||
),
|
||||
changelog_loading: create_formatted_text_from_string(CHANGELOG_LOADING_MSG.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_changelog_event(&mut self, _: &ChangelogEvent, ctx: &mut ViewContext<Self>) {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Generate the changelog items for the 'New Features' section and add them to the content
|
||||
///
|
||||
/// This is distinct from the additional sections because the 'New Features' section has
|
||||
/// custom logic around displaying the header differently and displaying an image (if
|
||||
/// available)
|
||||
fn generate_new_features_section(
|
||||
&self,
|
||||
content: &mut Flex,
|
||||
model: &ChangelogModel,
|
||||
appearance: &Appearance,
|
||||
) {
|
||||
let title = ChangelogHeader::NewFeatures.to_string();
|
||||
let icon = icons::Icon::Gift;
|
||||
let Some(markdown) = model.parsed_changelog.get(&title) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Section Title
|
||||
if self.show_special_new_features_header {
|
||||
content.add_child(render_special_changelog_header(
|
||||
&title,
|
||||
render_icon(icon, appearance.theme().terminal_colors().normal.red.into()),
|
||||
appearance,
|
||||
));
|
||||
} else {
|
||||
content.add_child(render_basic_changelog_header(
|
||||
&title,
|
||||
render_icon(
|
||||
icon,
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2()),
|
||||
),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
// Image (if available)
|
||||
if let Some(image_source) = &model.image {
|
||||
content.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Image::new(image_source.clone(), CacheOption::BySize)
|
||||
.enable_animation_with_start_time(Instant::now())
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(200.)
|
||||
.with_max_width(350.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
// Content
|
||||
content.add_child(render_changelog_body(
|
||||
markdown.clone(),
|
||||
self.new_features_highlighted_link.clone(),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
/// Generate all of the supported changelog sections and add them to the content
|
||||
///
|
||||
/// The supported sections are, in order:
|
||||
///
|
||||
/// * New features
|
||||
/// * Improvements
|
||||
/// * Bug fixes
|
||||
fn generate_changelog_sections(
|
||||
&self,
|
||||
content: &mut Flex,
|
||||
model: &ChangelogModel,
|
||||
appearance: &Appearance,
|
||||
) {
|
||||
self.generate_new_features_section(content, model, appearance);
|
||||
|
||||
let additional_sections = [
|
||||
(
|
||||
ChangelogHeader::Improvements,
|
||||
icons::Icon::Tool,
|
||||
self.improvements_highlighted_link.clone(),
|
||||
),
|
||||
(
|
||||
ChangelogHeader::BugFixes,
|
||||
icons::Icon::Bug,
|
||||
self.bug_fixes_highlighted_link.clone(),
|
||||
),
|
||||
];
|
||||
|
||||
for (section, icon, link) in additional_sections {
|
||||
let title = section.to_string();
|
||||
let Some(markdown) = model.parsed_changelog.get(&title) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Title
|
||||
content.add_child(render_basic_changelog_header(
|
||||
&title,
|
||||
render_icon(
|
||||
icon,
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2()),
|
||||
),
|
||||
appearance,
|
||||
));
|
||||
|
||||
// Content
|
||||
content.add_child(render_changelog_body(markdown.clone(), link, appearance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_icon(icon: icons::Icon, color: Fill) -> ConstrainedBox {
|
||||
ConstrainedBox::new(Icon::new(icon.into(), color).finish())
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
}
|
||||
|
||||
fn render_special_changelog_header(
|
||||
title: &str,
|
||||
icon: ConstrainedBox,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_child(icon.finish())
|
||||
.with_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(title.to_ascii_uppercase())
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().failed_block_color().into()),
|
||||
font_weight: Some(Weight::Bold),
|
||||
font_size: Some(16.0),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(4.)
|
||||
.with_border(
|
||||
Border::all(1.0)
|
||||
.with_border_fill::<Fill>(appearance.theme().terminal_colors().normal.red.into()),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(12.)
|
||||
.with_margin_right(158.)
|
||||
.with_margin_left(16.)
|
||||
.with_margin_bottom(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_basic_changelog_header(
|
||||
title: &str,
|
||||
icon: ConstrainedBox,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_child(icon.finish())
|
||||
.with_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(title.to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2())
|
||||
.into(),
|
||||
),
|
||||
font_weight: Some(Weight::Normal),
|
||||
font_size: Some(16.0),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(12.)
|
||||
.with_margin_right(16.)
|
||||
.with_margin_left(16.)
|
||||
.with_margin_bottom(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_changelog_body(
|
||||
parsed_markdown: FormattedText,
|
||||
highlighted_link: HighlightedHyperlink,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
FormattedTextElement::new(
|
||||
parsed_markdown,
|
||||
14.0,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
highlighted_link,
|
||||
)
|
||||
.register_default_click_handlers(move |url, ctx, _| {
|
||||
ctx.dispatch_typed_action(SectionAction::OpenUrl(url.url));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(12.)
|
||||
.with_margin_right(16.)
|
||||
.with_margin_left(16.)
|
||||
.with_margin_bottom(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
impl SectionView for ChangelogSectionView {
|
||||
fn is_expanded(&self) -> bool {
|
||||
self.is_expanded
|
||||
}
|
||||
|
||||
fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_expanded = !self.is_expanded;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn section_progress_indicator(
|
||||
&self,
|
||||
_show_gamified: bool,
|
||||
_appearance: &Appearance,
|
||||
_ctx: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn section_link(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
||||
Some(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
"Read all changelogs".into(),
|
||||
Some("https://docs.warp.dev/changelog".into()),
|
||||
None,
|
||||
self.changelog_button_mouse_states
|
||||
.view_changelogs_mouse_state
|
||||
.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(UiComponentStyles {
|
||||
border_width: Some(2.),
|
||||
font_size: Some(14.0),
|
||||
font_weight: Some(Weight::Normal),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl View for ChangelogSectionView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ResourceCenterChangelogSectionView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let changelog_model = app.model(&self.changelog_model_handle);
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let header = self.render_section_header(
|
||||
FeatureSection::WhatsNew,
|
||||
false,
|
||||
appearance,
|
||||
self.changelog_button_mouse_states
|
||||
.top_bar_mouse_state
|
||||
.clone(),
|
||||
app,
|
||||
);
|
||||
|
||||
let mut section = Flex::column().with_child(header);
|
||||
|
||||
if self.is_expanded || FeatureFlag::AvatarInTabBar.is_enabled() {
|
||||
let mut content_flex = Flex::column();
|
||||
match &changelog_model.changelog {
|
||||
ChangelogState::Some(_) => {
|
||||
self.generate_changelog_sections(
|
||||
&mut content_flex,
|
||||
changelog_model,
|
||||
appearance,
|
||||
);
|
||||
}
|
||||
ChangelogState::Pending => {
|
||||
content_flex.add_child(render_changelog_body(
|
||||
self.changelog_loading.clone(),
|
||||
self.new_features_highlighted_link.clone(),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
ChangelogState::None => {
|
||||
content_flex.add_child(render_changelog_body(
|
||||
self.changelog_fetch_error.clone(),
|
||||
self.new_features_highlighted_link.clone(),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let content_section = Container::new(content_flex.finish())
|
||||
.with_margin_top(4.)
|
||||
.with_margin_bottom(4.);
|
||||
section.add_child(content_section.finish());
|
||||
}
|
||||
|
||||
section.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, Element, Empty, Flex, MouseStateHandle, ParentElement,
|
||||
Shrinkable,
|
||||
},
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
resource_center::{ContentItem, ContentSectionData},
|
||||
};
|
||||
|
||||
use super::{
|
||||
SectionAction, SectionView, CHEVRON_ICON_SIZE, DESCRIPTION_FONT_SIZE, ICON_PADDING,
|
||||
ITEM_PADDING_BOTTOM, SECTION_SPACING,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ContentMouseStateHandles {
|
||||
item_handles: Vec<MouseStateHandle>,
|
||||
top_bar_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct ContentSectionView {
|
||||
content_section_data: ContentSectionData,
|
||||
content_button_mouse_states: ContentMouseStateHandles,
|
||||
is_expanded: bool,
|
||||
}
|
||||
|
||||
impl Entity for ContentSectionView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TypedActionView for ContentSectionView {
|
||||
type Action = SectionAction;
|
||||
|
||||
fn handle_action(&mut self, action: &SectionAction, ctx: &mut ViewContext<Self>) {
|
||||
use SectionAction::*;
|
||||
match action {
|
||||
OpenUrl(url) => {
|
||||
ctx.open_url(url.as_str());
|
||||
}
|
||||
ToggleExpanded => self.toggle_expanded(ctx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentSectionView {
|
||||
pub fn new(
|
||||
content_section_data: ContentSectionData,
|
||||
is_expanded: bool,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let content_button_mouse_states = ContentMouseStateHandles {
|
||||
item_handles: content_section_data
|
||||
.items
|
||||
.iter()
|
||||
.map(|_| Default::default())
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Self {
|
||||
content_section_data,
|
||||
content_button_mouse_states,
|
||||
is_expanded,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_link_button(
|
||||
&self,
|
||||
item: &ContentItem,
|
||||
appearance: &Appearance,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let default_link_styles = UiComponentStyles {
|
||||
font_size: Some(13.),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(ColorU::from(
|
||||
theme
|
||||
.accent()
|
||||
.on_background(theme.surface_2(), MinimumAllowedContrast::Text),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hovered_and_clicked_styles = UiComponentStyles {
|
||||
font_color: Some(ColorU::from(theme.active_ui_text_color())),
|
||||
..default_link_styles
|
||||
};
|
||||
|
||||
Flex::row()
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
item.button_label.to_string(),
|
||||
Some(item.url.into()),
|
||||
None,
|
||||
mouse_state_handle,
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(default_link_styles)
|
||||
.with_hovered_style(hovered_and_clicked_styles)
|
||||
.with_clicked_style(hovered_and_clicked_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Shrinkable::new(1., Empty::new().finish()).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_content_item(
|
||||
&self,
|
||||
item: &ContentItem,
|
||||
appearance: &Appearance,
|
||||
index: usize,
|
||||
) -> Box<dyn Element> {
|
||||
let mut element = Flex::column();
|
||||
let mouse_state = self.content_button_mouse_states.item_handles[index].clone();
|
||||
let link_button = self.render_link_button(item, appearance, mouse_state);
|
||||
|
||||
// title
|
||||
element.add_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(item.title.to_string(), true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(DESCRIPTION_FONT_SIZE),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(ITEM_PADDING_BOTTOM)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// description
|
||||
element.add_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(item.description.to_string(), true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(DESCRIPTION_FONT_SIZE),
|
||||
font_color: Some(ColorU::from(
|
||||
appearance.theme().nonactive_ui_text_color(),
|
||||
)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(ITEM_PADDING_BOTTOM)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// link
|
||||
element.add_child(link_button);
|
||||
|
||||
Container::new(element.finish())
|
||||
.with_margin_bottom(SECTION_SPACING)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SectionView for ContentSectionView {
|
||||
fn is_expanded(&self) -> bool {
|
||||
self.is_expanded
|
||||
}
|
||||
|
||||
fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_expanded = !self.is_expanded;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn section_progress_indicator(
|
||||
&self,
|
||||
_show_gamified: bool,
|
||||
_appearance: &Appearance,
|
||||
_ctx: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn section_link(&self, _appearance: &Appearance) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl View for ContentSectionView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ResourceCenterContentSectionView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let header = self.render_section_header(
|
||||
self.content_section_data.section_name,
|
||||
false,
|
||||
appearance,
|
||||
self.content_button_mouse_states.top_bar_mouse_state.clone(),
|
||||
app,
|
||||
);
|
||||
|
||||
let mut section = Flex::column().with_child(header);
|
||||
if self.is_expanded {
|
||||
let content_section =
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_children(
|
||||
self.content_section_data.items.iter().enumerate().map(
|
||||
|(index, item)| self.render_content_item(item, appearance, index),
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_margin(SECTION_SPACING)
|
||||
.with_margin_left(SECTION_SPACING + CHEVRON_ICON_SIZE + ICON_PADDING);
|
||||
|
||||
section.add_child(content_section.finish());
|
||||
}
|
||||
|
||||
ConstrainedBox::new(Container::new(section.finish()).finish()).finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
send_telemetry_from_ctx,
|
||||
server::telemetry::TelemetryEvent,
|
||||
settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier},
|
||||
themes::theme::Fill,
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, Hoverable, Icon,
|
||||
MouseState, MouseStateHandle, ParentElement, Shrinkable,
|
||||
},
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
Action, AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, WindowId,
|
||||
};
|
||||
|
||||
use crate::resource_center::{
|
||||
complete_tips_and_write_to_user_defaults, main_page::ActionTarget,
|
||||
skip_tips_and_write_to_user_defaults, FeatureItem, FeatureSectionData, Tip, TipsCompleted,
|
||||
};
|
||||
|
||||
use super::{
|
||||
SectionAction, SectionView, CHEVRON_ICON_SIZE, DESCRIPTION_FONT_SIZE, ELLIPSE_ICON_SIZE,
|
||||
ELLIPSE_SVG_PATH, ICON_PADDING, ITEM_PADDING_BOTTOM, SCROLLBAR_OFFSET, SECTION_SPACING,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FeatureSection {
|
||||
WhatsNew,
|
||||
GettingStarted,
|
||||
MaximizeWarp,
|
||||
AdvancedSetup,
|
||||
}
|
||||
|
||||
impl FeatureSection {
|
||||
pub fn section_name_string(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureSection::WhatsNew => "What's New?",
|
||||
FeatureSection::GettingStarted => "Getting Started",
|
||||
FeatureSection::MaximizeWarp => "Maximize Warp",
|
||||
FeatureSection::AdvancedSetup => "Advanced Setup",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FeatureMouseStateHandles {
|
||||
item_handles: Vec<MouseStateHandle>,
|
||||
top_bar_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub enum FeatureSectionEvent {
|
||||
/// Event fired when the tips dialog should close.
|
||||
CloseResourceCenter,
|
||||
ExpandSection(FeatureSection),
|
||||
}
|
||||
|
||||
pub struct FeatureSectionView {
|
||||
pub feature_section_data: FeatureSectionData,
|
||||
action_target: ModelHandle<ActionTarget>,
|
||||
feature_button_mouse_states: FeatureMouseStateHandles,
|
||||
tips_completed: ModelHandle<TipsCompleted>,
|
||||
show_tips_progress: bool,
|
||||
is_expanded: bool,
|
||||
}
|
||||
|
||||
impl FeatureSectionView {
|
||||
fn on_tips_model_changed(
|
||||
&mut self,
|
||||
_: ModelHandle<TipsCompleted>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
feature_section_data: FeatureSectionData,
|
||||
action_target: ModelHandle<ActionTarget>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
tips_completed: ModelHandle<TipsCompleted>,
|
||||
show_tips_progress: bool,
|
||||
is_expanded: bool,
|
||||
) -> Self {
|
||||
let feature_button_mouse_states = FeatureMouseStateHandles {
|
||||
item_handles: feature_section_data
|
||||
.items
|
||||
.iter()
|
||||
.map(|_| Default::default())
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ctx.observe(&tips_completed, FeatureSectionView::on_tips_model_changed);
|
||||
|
||||
let bindings_notifier = KeybindingChangedNotifier::handle(ctx);
|
||||
ctx.subscribe_to_model(&bindings_notifier, |me, _, event, ctx| {
|
||||
me.handle_keybinding_changed(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
feature_section_data,
|
||||
action_target,
|
||||
feature_button_mouse_states,
|
||||
tips_completed,
|
||||
show_tips_progress,
|
||||
is_expanded,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_keybinding_changed(
|
||||
&mut self,
|
||||
event: &KeybindingChangedEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
KeybindingChangedEvent::BindingChanged {
|
||||
binding_name,
|
||||
new_trigger,
|
||||
} => {
|
||||
if let Some(binding) = self
|
||||
.feature_section_data
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|data| data.editable_binding_name == Some(binding_name))
|
||||
{
|
||||
binding.shortcut.clone_from(new_trigger);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_section(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_expanded = true;
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_expanded = !self.is_expanded;
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
// Turns gamification off without rendering completed modal
|
||||
pub fn skip_gamified_section(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
send_telemetry_from_ctx!(TelemetryEvent::ResourceCenterTipsSkipped, ctx);
|
||||
self.tips_completed.update(ctx, |tips_completed, ctx| {
|
||||
skip_tips_and_write_to_user_defaults(tips_completed, ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
// Turns gamification off and renders completed modal
|
||||
pub fn complete_gamified_section(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
send_telemetry_from_ctx!(TelemetryEvent::ResourceCenterTipsCompleted, ctx);
|
||||
self.tips_completed.update(ctx, |tips_completed, ctx| {
|
||||
complete_tips_and_write_to_user_defaults(tips_completed, ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_action_target(
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
input_id: Option<EntityId>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.action_target.update(ctx, |action_target, ctx| {
|
||||
*action_target = ActionTarget::View {
|
||||
window_id,
|
||||
input_id,
|
||||
};
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn dispatch_feature_action(&self, action: &dyn Action, ctx: &mut ViewContext<Self>) {
|
||||
let (window_id, input_id) = match self.action_target.as_ref(ctx) {
|
||||
ActionTarget::View {
|
||||
window_id,
|
||||
input_id,
|
||||
} => (*window_id, *input_id),
|
||||
ActionTarget::None => return,
|
||||
};
|
||||
|
||||
if let Some(input_id) = input_id {
|
||||
ctx.dispatch_typed_action_for_view(window_id, input_id, action);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_unread_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::new(ELLIPSE_SVG_PATH, appearance.theme().accent()).finish())
|
||||
.with_height(ELLIPSE_ICON_SIZE)
|
||||
.with_width(ELLIPSE_ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(ELLIPSE_ICON_SIZE)
|
||||
.with_padding_right(ELLIPSE_ICON_SIZE)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item_title(&self, item: &FeatureItem, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let title_color = appearance.theme().active_ui_text_color();
|
||||
|
||||
Align::new(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(item.title.to_string(), true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(DESCRIPTION_FONT_SIZE),
|
||||
font_color: (Some(title_color.into())),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(3.)
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_description(
|
||||
&self,
|
||||
item: &FeatureItem,
|
||||
appearance: &Appearance,
|
||||
color: Fill,
|
||||
) -> Box<dyn Element> {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(item.description.to_string(), true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(DESCRIPTION_FONT_SIZE),
|
||||
font_color: Some(color.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn build_feature_item(
|
||||
&self,
|
||||
item: &FeatureItem,
|
||||
appearance: &Appearance,
|
||||
state: Option<&MouseState>,
|
||||
is_completed: bool,
|
||||
show_gamified: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let ui_builder = appearance.ui_builder();
|
||||
let mut element = Flex::column();
|
||||
let mut element_title = Flex::row();
|
||||
|
||||
// title
|
||||
element_title
|
||||
.add_child(Shrinkable::new(1., self.render_item_title(item, appearance)).finish());
|
||||
|
||||
// keyboard shortcut
|
||||
if let Some(keystroke) = &item.shortcut {
|
||||
element_title.add_child(ui_builder.keyboard_shortcut(keystroke).build().finish())
|
||||
}
|
||||
|
||||
element.add_child(
|
||||
Container::new(
|
||||
element_title
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(ITEM_PADDING_BOTTOM)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let hovered = state.is_some() && state.expect("Expected valid mouse state").is_hovered();
|
||||
let description_color = if hovered && matches!(item.feature, Tip::Action(_)) {
|
||||
theme.active_ui_text_color()
|
||||
} else {
|
||||
theme.nonactive_ui_text_color()
|
||||
};
|
||||
|
||||
// description
|
||||
element.add_child(self.render_description(item, appearance, description_color));
|
||||
|
||||
let mut feature_item = Flex::row();
|
||||
if !is_completed && show_gamified {
|
||||
feature_item.add_child(self.render_unread_icon(appearance));
|
||||
}
|
||||
feature_item.add_child(Shrinkable::new(1., element.finish()).finish());
|
||||
|
||||
let margin_left = if is_completed || !show_gamified {
|
||||
CHEVRON_ICON_SIZE + ICON_PADDING
|
||||
} else {
|
||||
SCROLLBAR_OFFSET
|
||||
};
|
||||
Container::new(feature_item.finish())
|
||||
.with_margin_bottom(SECTION_SPACING)
|
||||
.with_margin_left(margin_left)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_feature_item(
|
||||
&self,
|
||||
feature_item: FeatureItem,
|
||||
appearance: &Appearance,
|
||||
index: usize,
|
||||
is_tip_completed: bool,
|
||||
show_gamified: bool,
|
||||
) -> Box<dyn Element> {
|
||||
match feature_item.feature {
|
||||
Tip::Hint(_) => self.build_feature_item(
|
||||
&feature_item,
|
||||
appearance,
|
||||
None,
|
||||
is_tip_completed,
|
||||
show_gamified,
|
||||
),
|
||||
Tip::Action(tip) => {
|
||||
let item_element = Hoverable::new(
|
||||
self.feature_button_mouse_states.item_handles[index].clone(),
|
||||
|state| {
|
||||
self.build_feature_item(
|
||||
&feature_item,
|
||||
appearance,
|
||||
Some(state),
|
||||
is_tip_completed,
|
||||
show_gamified,
|
||||
)
|
||||
},
|
||||
);
|
||||
item_element
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(SectionAction::Click(tip)))
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FeatureSectionView {
|
||||
type Event = FeatureSectionEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for FeatureSectionView {
|
||||
type Action = SectionAction;
|
||||
|
||||
fn handle_action(&mut self, action: &SectionAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SectionAction::Click(feature) => {
|
||||
let action = ctx
|
||||
.editable_bindings()
|
||||
.find(|action| action.name == feature.editable_binding_name())
|
||||
.map(|action| action.action.clone());
|
||||
|
||||
if let Some(action) = action {
|
||||
self.dispatch_feature_action(action.as_ref(), ctx);
|
||||
}
|
||||
}
|
||||
SectionAction::ToggleExpanded => {
|
||||
self.toggle_expanded(ctx);
|
||||
}
|
||||
SectionAction::CloseResourceCenter => {
|
||||
self.toggle_expanded(ctx);
|
||||
ctx.emit(FeatureSectionEvent::CloseResourceCenter);
|
||||
ctx.notify();
|
||||
}
|
||||
SectionAction::CompleteGamified => {
|
||||
self.complete_gamified_section(ctx);
|
||||
self.toggle_expanded(ctx);
|
||||
}
|
||||
SectionAction::SkipTips => {
|
||||
self.skip_gamified_section(ctx);
|
||||
self.toggle_expanded(ctx);
|
||||
}
|
||||
SectionAction::OpenSection(section_name) => {
|
||||
ctx.emit(FeatureSectionEvent::ExpandSection(*section_name));
|
||||
ctx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectionView for FeatureSectionView {
|
||||
fn is_expanded(&self) -> bool {
|
||||
self.is_expanded
|
||||
}
|
||||
|
||||
fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_expanded = !self.is_expanded;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn section_progress_indicator(
|
||||
&self,
|
||||
show_gamified: bool,
|
||||
appearance: &Appearance,
|
||||
ctx: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let tip_count = self.feature_section_data.items.len();
|
||||
let tips_completed_count = self
|
||||
.feature_section_data
|
||||
.tips_completed_count(self.tips_completed.as_ref(ctx));
|
||||
|
||||
// Show progress when section's tips are not yet completed
|
||||
if show_gamified && self.show_tips_progress && tips_completed_count != tip_count {
|
||||
let progress = format!("{tips_completed_count}/{tip_count}");
|
||||
Some(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(progress, false)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(DESCRIPTION_FONT_SIZE),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn section_link(&self, _appearance: &Appearance) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl View for FeatureSectionView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ResourceCenterFeatureSectionView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let tips_completed = self.tips_completed.as_ref(app);
|
||||
let show_gamified = !tips_completed.skipped_or_completed;
|
||||
|
||||
let header = self.render_section_header(
|
||||
self.feature_section_data.section_name,
|
||||
show_gamified,
|
||||
appearance,
|
||||
self.feature_button_mouse_states.top_bar_mouse_state.clone(),
|
||||
app,
|
||||
);
|
||||
|
||||
let mut section = Flex::column().with_child(header);
|
||||
if self.is_expanded {
|
||||
let mut feature_section = Container::new(
|
||||
Flex::column()
|
||||
.with_children(self.feature_section_data.items.iter().enumerate().map(
|
||||
|(index, feature_item)| {
|
||||
self.render_feature_item(
|
||||
feature_item.clone(),
|
||||
appearance,
|
||||
index,
|
||||
tips_completed.features_used.contains(&feature_item.feature),
|
||||
show_gamified,
|
||||
)
|
||||
},
|
||||
))
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if !self.feature_section_data.items.is_empty() {
|
||||
feature_section = feature_section.with_uniform_padding(SECTION_SPACING)
|
||||
}
|
||||
|
||||
section.add_child(feature_section.finish());
|
||||
}
|
||||
|
||||
ConstrainedBox::new(Container::new(section.finish()).finish()).finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
pub mod feature_section;
|
||||
pub use feature_section::FeatureSectionView;
|
||||
pub mod content_section;
|
||||
pub use content_section::ContentSectionView;
|
||||
use warp_core::features::FeatureFlag;
|
||||
pub mod changelog_section;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
resource_center::{section_views::feature_section::FeatureSection, TipAction},
|
||||
};
|
||||
pub use changelog_section::ChangelogSectionView;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, Hoverable,
|
||||
Icon, MouseStateHandle, ParentElement, ScrollbarWidth, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
pub const HEADER_FONT_SIZE: f32 = 16.;
|
||||
pub const SECTION_HEADER_FONT_SIZE: f32 = 16.;
|
||||
pub const DESCRIPTION_FONT_SIZE: f32 = 14.;
|
||||
pub const DETAIL_FONT_SIZE: f32 = 12.;
|
||||
|
||||
pub const KEYBOARD_ICON_SIZE: f32 = 30.;
|
||||
pub const CHEVRON_ICON_SIZE: f32 = 20.;
|
||||
pub const FOOTER_ICON_SIZE: f32 = 15.;
|
||||
pub const ELLIPSE_ICON_SIZE: f32 = 8.;
|
||||
pub const ICON_PADDING: f32 = 3.;
|
||||
|
||||
pub const DROPDOWN_ICON_OPACITY: u8 = 75;
|
||||
|
||||
// TODO: update scrollbar behaviour to not take up space when non-active
|
||||
// Spacing to offset scrollbar width (which makes things off-centered)
|
||||
pub const SCROLLBAR_OFFSET: f32 = 7.;
|
||||
pub const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto;
|
||||
|
||||
pub const SECTION_SPACING_BOTTOM: f32 = 24.;
|
||||
pub const SECTION_SPACING: f32 = 12.;
|
||||
pub const BUTTON_PADDING: f32 = 10.;
|
||||
pub const ITEM_PADDING_BOTTOM: f32 = 6.;
|
||||
|
||||
pub const CHEVRON_DOWN_SKINNY_SVG_PATH: &str = "bundled/svg/chevron-down-skinny.svg";
|
||||
pub const CHEVRON_RIGHT_SKINNY_SVG_PATH: &str = "bundled/svg/chevron-right-skinny.svg";
|
||||
pub const ELLIPSE_SVG_PATH: &str = "bundled/svg/ellipse.svg";
|
||||
|
||||
pub enum SectionViewHandle {
|
||||
Feature(ViewHandle<FeatureSectionView>),
|
||||
Content(ViewHandle<ContentSectionView>),
|
||||
Changelog(ViewHandle<ChangelogSectionView>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SectionAction {
|
||||
OpenUrl(String),
|
||||
ToggleExpanded,
|
||||
Click(TipAction),
|
||||
CloseResourceCenter,
|
||||
CompleteGamified,
|
||||
SkipTips,
|
||||
OpenSection(FeatureSection),
|
||||
}
|
||||
|
||||
pub trait SectionView {
|
||||
fn is_expanded(&self) -> bool;
|
||||
|
||||
fn toggle_expanded(&mut self, ctx: &mut ViewContext<Self>);
|
||||
|
||||
fn section_progress_indicator(
|
||||
&self,
|
||||
show_gamified: bool,
|
||||
appearance: &Appearance,
|
||||
ctx: &AppContext,
|
||||
) -> Option<Box<dyn Element>>;
|
||||
|
||||
fn section_link(&self, appearance: &Appearance) -> Option<Box<dyn Element>>;
|
||||
|
||||
fn render_section_header(
|
||||
&self,
|
||||
section_name: FeatureSection,
|
||||
show_gamified: bool,
|
||||
appearance: &Appearance,
|
||||
top_bar_mouse_state: MouseStateHandle,
|
||||
ctx: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
Hoverable::new(top_bar_mouse_state, |state| {
|
||||
let mut section_header = Flex::row();
|
||||
|
||||
let section_title = Shrinkable::new(
|
||||
1.0,
|
||||
Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(section_name.section_name_string().to_string(), false)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(SECTION_HEADER_FONT_SIZE),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let icon_path = if self.is_expanded() {
|
||||
CHEVRON_DOWN_SKINNY_SVG_PATH
|
||||
} else {
|
||||
CHEVRON_RIGHT_SKINNY_SVG_PATH
|
||||
};
|
||||
|
||||
let icon_color = if state.is_hovered() {
|
||||
appearance.theme().active_ui_detail()
|
||||
} else {
|
||||
appearance
|
||||
.theme()
|
||||
.active_ui_detail()
|
||||
.with_opacity(DROPDOWN_ICON_OPACITY)
|
||||
};
|
||||
|
||||
let dropdown_icon =
|
||||
ConstrainedBox::new(Icon::new(icon_path, icon_color.into_solid()).finish())
|
||||
.with_height(CHEVRON_ICON_SIZE)
|
||||
.with_width(CHEVRON_ICON_SIZE)
|
||||
.finish();
|
||||
|
||||
if !FeatureFlag::AvatarInTabBar.is_enabled() {
|
||||
section_header.add_child(dropdown_icon);
|
||||
}
|
||||
section_header.add_child(section_title);
|
||||
|
||||
if let Some(progress_indicator) =
|
||||
self.section_progress_indicator(show_gamified, appearance, ctx)
|
||||
{
|
||||
section_header.add_child(progress_indicator)
|
||||
}
|
||||
|
||||
if let Some(link) = self.section_link(appearance) {
|
||||
section_header.add_child(link)
|
||||
}
|
||||
|
||||
Container::new(
|
||||
section_header
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(SECTION_SPACING)
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_border(
|
||||
Border::top(1.)
|
||||
.with_border_color(appearance.theme().split_pane_border_color().into()),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(SectionAction::ToggleExpanded))
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use warp_core::{context_flag::ContextFlag, features::FeatureFlag};
|
||||
use warpui::ViewContext;
|
||||
|
||||
use super::{
|
||||
ContentItem, ContentSectionData, FeatureItem, FeatureSection, FeatureSectionData,
|
||||
ResourceCenterMainView, Section, Tip, TipAction, TipHint,
|
||||
};
|
||||
|
||||
pub fn sections(ctx: &mut ViewContext<ResourceCenterMainView>) -> Vec<Section> {
|
||||
let mut sections = vec![Section::Changelog()];
|
||||
|
||||
if FeatureFlag::AvatarInTabBar.is_enabled() {
|
||||
return sections;
|
||||
}
|
||||
|
||||
let get_started = FeatureSectionData {
|
||||
section_name: FeatureSection::GettingStarted,
|
||||
items: vec![
|
||||
FeatureItem::new(
|
||||
"Create your first block",
|
||||
"Run a command to see your command and output grouped.",
|
||||
Tip::Hint(TipHint::CreateBlock),
|
||||
ctx,
|
||||
),
|
||||
FeatureItem::new(
|
||||
"Navigate blocks",
|
||||
"Click to select a block and navigate with arrow keys.",
|
||||
Tip::Hint(TipHint::BlockSelect),
|
||||
ctx,
|
||||
),
|
||||
FeatureItem::new(
|
||||
"Take an action on block",
|
||||
"Right click on a block to copy/paste, share, more.",
|
||||
Tip::Hint(TipHint::BlockAction),
|
||||
ctx,
|
||||
),
|
||||
FeatureItem::new(
|
||||
"Open command palette",
|
||||
"Access all of Warp via the keyboard.",
|
||||
Tip::Action(TipAction::CommandPalette),
|
||||
ctx,
|
||||
),
|
||||
FeatureItem::new(
|
||||
"Set your theme",
|
||||
"Make Warp your own by choosing a theme.",
|
||||
Tip::Action(TipAction::ThemePicker),
|
||||
ctx,
|
||||
),
|
||||
],
|
||||
};
|
||||
sections.push(Section::Feature(get_started));
|
||||
|
||||
let maximize_warp = FeatureSectionData {
|
||||
section_name: FeatureSection::MaximizeWarp,
|
||||
items: maximize_warp_items(ctx),
|
||||
};
|
||||
sections.push(Section::Feature(maximize_warp));
|
||||
|
||||
let advanced_setup = ContentSectionData {
|
||||
section_name: FeatureSection::AdvancedSetup,
|
||||
items: vec![
|
||||
ContentItem {
|
||||
title: "Use your custom prompt",
|
||||
description: "Set up Warp to honor your PS1 setting",
|
||||
url: "https://docs.warp.dev/terminal/appearance/prompt",
|
||||
button_label: "View documentation",
|
||||
},
|
||||
ContentItem {
|
||||
title: "Integrate Warp with your IDE",
|
||||
description: "Configure Warp to launch from your most used development tools",
|
||||
url: "https://docs.warp.dev/terminal/integrations-and-plugins",
|
||||
button_label: "View documentation",
|
||||
},
|
||||
ContentItem {
|
||||
title: "How Warp uses Warp",
|
||||
description: "Learn how Warp's engineering team uses their favorite features",
|
||||
url: "https://www.warp.dev/blog/how-warp-uses-warp",
|
||||
button_label: "Read article",
|
||||
},
|
||||
],
|
||||
};
|
||||
sections.push(Section::Content(advanced_setup));
|
||||
|
||||
sections
|
||||
}
|
||||
|
||||
fn maximize_warp_items(ctx: &mut ViewContext<ResourceCenterMainView>) -> Vec<FeatureItem> {
|
||||
let mut maximize_warp_items = vec![];
|
||||
|
||||
maximize_warp_items.push(FeatureItem::new(
|
||||
"Command search",
|
||||
"Find and run previously executed commands, workflows, and more.",
|
||||
Tip::Action(TipAction::CommandSearch),
|
||||
ctx,
|
||||
));
|
||||
|
||||
maximize_warp_items.push(FeatureItem::new(
|
||||
"AI command search",
|
||||
"Generate shell commands with natural language.",
|
||||
Tip::Action(TipAction::AiCommandSearch),
|
||||
ctx,
|
||||
));
|
||||
|
||||
if ContextFlag::CreateNewSession.is_enabled() {
|
||||
maximize_warp_items.push(FeatureItem::new(
|
||||
"Split panes",
|
||||
"Split tabs into multiple panes to make your ideal layout.",
|
||||
Tip::Action(TipAction::SplitPane),
|
||||
ctx,
|
||||
));
|
||||
}
|
||||
|
||||
if ContextFlag::LaunchConfigurations.is_enabled() {
|
||||
maximize_warp_items.push(FeatureItem::new(
|
||||
"Launch configuration",
|
||||
"Save your current configuration of windows, tabs, and panes.",
|
||||
Tip::Action(TipAction::SaveNewLaunchConfig),
|
||||
ctx,
|
||||
));
|
||||
}
|
||||
|
||||
maximize_warp_items
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Hard coded constants to divide keybindings into their respective categories/sections.
|
||||
// This should always align with documentation: https://docs.warp.dev/getting-started/keyboard-shortcuts
|
||||
|
||||
use warpui::keymap::Keystroke;
|
||||
|
||||
use crate::util::bindings::CommandBinding;
|
||||
|
||||
pub const BLOCKS_KEYBINDINGS: &[&str] = &[
|
||||
"terminal:select_bookmark_down",
|
||||
"terminal:copy_outputs",
|
||||
"terminal:select_bookmark_up",
|
||||
"terminal:select_all_blocks",
|
||||
"terminal:bookmark_selected_block",
|
||||
"terminal:select_next_block",
|
||||
"terminal:reinput_commands",
|
||||
"terminal:focus_input",
|
||||
"terminal:select_previous_block",
|
||||
"terminal:open_block_list_context_menu_via_keybinding",
|
||||
"terminal:copy_commands",
|
||||
"terminal:reinput_commands_with_sudo",
|
||||
"terminal:open_share_block_modal",
|
||||
"terminal:expand_block_selection_below",
|
||||
"terminal:expand_block_selection_above",
|
||||
"terminal:clear_blocks",
|
||||
];
|
||||
|
||||
pub const INPUT_EDITOR_KEYBINDINGS: &[&str] = &[
|
||||
"input:clear_screen",
|
||||
"editor:delete_word_left",
|
||||
"editor:delete_word_right",
|
||||
"editor:insert_last_word_previous_command",
|
||||
"editor:select_to_line_end",
|
||||
"editor:select_to_line_start",
|
||||
"editor_view:add_cursor_above",
|
||||
"editor_view:add_cursor_below",
|
||||
"editor_view:add_next_occurrence",
|
||||
"editor_view:backspace",
|
||||
"editor_view:clear_and_copy_lines",
|
||||
"editor_view:clear_buffer",
|
||||
"editor_view:clear_lines",
|
||||
"editor_view:cmd_down",
|
||||
"editor_view:inspect_command",
|
||||
"editor_view:cut_all_right",
|
||||
"editor_view:cut_word_left",
|
||||
"editor_view:cut_word_right",
|
||||
"editor_view:delete",
|
||||
"editor_view:delete_all_left",
|
||||
"editor_view:delete_all_right",
|
||||
"editor_view:down",
|
||||
"editor_view:end",
|
||||
"editor_view:fold",
|
||||
"editor_view:fold_selected_ranges",
|
||||
"editor_view:home",
|
||||
"editor_view:insert_newline",
|
||||
"editor_view:left",
|
||||
"editor_view:move_backward_one_subword",
|
||||
"editor_view:move_backward_one_word",
|
||||
"editor_view:move_forward_one_subword",
|
||||
"editor_view:move_forward_one_word",
|
||||
"editor_view:move_to_buffer_end",
|
||||
"editor_view:move_to_buffer_start",
|
||||
"editor_view:move_to_line_end",
|
||||
"editor_view:move_to_line_start",
|
||||
"editor_view:move_to_paragraph_end",
|
||||
"editor_view:move_to_paragraph_start",
|
||||
"editor_view:right",
|
||||
"editor_view:select_all",
|
||||
"editor_view:select_down",
|
||||
"editor_view:select_left",
|
||||
"editor_view:select_left_by_subword",
|
||||
"editor_view:select_left_by_word",
|
||||
"editor_view:select_right",
|
||||
"editor_view:select_right_by_subword",
|
||||
"editor_view:select_right_by_word",
|
||||
"editor_view:select_up",
|
||||
"editor_view:unfold",
|
||||
"editor_view:up",
|
||||
];
|
||||
|
||||
pub const TERMINAL_KEYBINDINGS: &[&str] = &[
|
||||
"find:find_next_occurrence",
|
||||
"find:find_prev_occurrence",
|
||||
"workspace:set_a11y_concise_verbosity_level",
|
||||
"workspace:set_a11y_verbose_verbosity_level",
|
||||
"workspace:show_command_search",
|
||||
"workspace:show_keybinding_settings",
|
||||
"workspace:show_settings_account_page",
|
||||
"workspace:show_settings",
|
||||
"workspace:toggle_command_palette",
|
||||
"workspace:toggle_launch_config_palette",
|
||||
"workspace:toggle_mouse_reporting",
|
||||
"workspace:toggle_navigation_palette",
|
||||
"workspace:toggle_resource_center",
|
||||
"pane_group:add_down",
|
||||
"pane_group:navigate_down",
|
||||
"pane_group:navigate_left",
|
||||
"pane_group:navigate_next",
|
||||
"pane_group:navigate_prev",
|
||||
"pane_group:navigate_right",
|
||||
"pane_group:navigate_up",
|
||||
"pane_group:resize_down",
|
||||
"pane_group:resize_left",
|
||||
"pane_group:resize_right",
|
||||
"pane_group:resize_up",
|
||||
"pane_group:toggle_maximize_pane",
|
||||
];
|
||||
|
||||
pub const FUNDAMENTALS_KEYBINDINGS: &[&str] = &[
|
||||
"workspace:new_window",
|
||||
"workspace:hide_warp",
|
||||
"workspace:hide_others",
|
||||
"workspace:quit_warp",
|
||||
"workspace:minimize",
|
||||
];
|
||||
|
||||
/// Returns hard-coded keybindings that are shown in the mac menus but not saved/accessible
|
||||
/// anywhere else in the code.
|
||||
pub fn get_additional_keybindings() -> Vec<CommandBinding> {
|
||||
vec![
|
||||
CommandBinding::new(
|
||||
"workspace:new_window".into(),
|
||||
"Open New Window".into(),
|
||||
Some(Keystroke::parse("cmd-n").expect("Valid keystroke")),
|
||||
),
|
||||
CommandBinding::new(
|
||||
"workspace:hide_warp".into(),
|
||||
"Hide Warp".into(),
|
||||
Some(Keystroke::parse("cmd-h").expect("Valid keystroke")),
|
||||
),
|
||||
CommandBinding::new(
|
||||
"workspace:hide_others".into(),
|
||||
"Hide Others".into(),
|
||||
Some(Keystroke::parse("alt-cmd-h").expect("Valid keystroke")),
|
||||
),
|
||||
CommandBinding::new(
|
||||
"workspace:quit_warp".into(),
|
||||
"Quit Warp".into(),
|
||||
Some(Keystroke::parse("cmd-q").expect("Valid keystroke")),
|
||||
),
|
||||
CommandBinding::new(
|
||||
"workspace:minimize".into(),
|
||||
"Minimize".into(),
|
||||
Some(Keystroke::parse("cmd-m").expect("Valid keystroke")),
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
use vec1::{vec1, Vec1};
|
||||
use warp_core::{features::FeatureFlag, ui::builder::AnimatedButtonOptions};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, Icon,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, SavePosition, Shrinkable,
|
||||
},
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
windowing::{StateEvent, WindowManager},
|
||||
AppContext, Entity, EntityId, FocusContext, ModelHandle, SingletonEntity, TypedActionView,
|
||||
View, ViewContext, ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use super::{
|
||||
keybindings_page::KeybindingsEvent,
|
||||
section_views::{
|
||||
FOOTER_ICON_SIZE, HEADER_FONT_SIZE, ICON_PADDING, KEYBOARD_ICON_SIZE, SCROLLBAR_OFFSET,
|
||||
SECTION_SPACING,
|
||||
},
|
||||
KeybindingsView, ResourceCenterMainEvent, ResourceCenterMainView, TipsCompleted,
|
||||
};
|
||||
use crate::ui_components::{buttons::icon_button, window_focus_dimming::WindowFocusDimming};
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
changelog_model::ChangelogModel,
|
||||
ui_components::icons,
|
||||
util::links,
|
||||
workspace::{WorkspaceAction, PANEL_HEADER_HEIGHT},
|
||||
};
|
||||
|
||||
// Footer icons
|
||||
const DOCS_SVG_PATH: &str = "bundled/svg/gitbook-logo.svg";
|
||||
const SLACK_SVG_PATH: &str = "bundled/svg/slack-logo.svg";
|
||||
const FEEDBACK_SVG_PATH: &str = "bundled/svg/feedback.svg";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ResourceCenterFooterItem {
|
||||
Docs,
|
||||
Slack,
|
||||
Feedback,
|
||||
}
|
||||
|
||||
impl ResourceCenterFooterItem {
|
||||
pub fn ui_label(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceCenterFooterItem::Docs => "Docs",
|
||||
ResourceCenterFooterItem::Slack => "Slack",
|
||||
ResourceCenterFooterItem::Feedback => "Feedback",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn svg_path(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceCenterFooterItem::Docs => DOCS_SVG_PATH,
|
||||
ResourceCenterFooterItem::Slack => SLACK_SVG_PATH,
|
||||
ResourceCenterFooterItem::Feedback => FEEDBACK_SVG_PATH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ResourceCenterPage {
|
||||
Main,
|
||||
Keybindings,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ResourceCenterPageView {
|
||||
pub page: ResourceCenterPage,
|
||||
pub page_view_handle: ResourceCenterViewHandle,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ResourceCenterViewHandle {
|
||||
Main(ViewHandle<ResourceCenterMainView>),
|
||||
Keybindings(ViewHandle<KeybindingsView>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
navigate_back: MouseStateHandle,
|
||||
open_keybindings: MouseStateHandle,
|
||||
close: MouseStateHandle,
|
||||
// Footer mouse state handles
|
||||
view_user_docs: MouseStateHandle,
|
||||
join_slack: MouseStateHandle,
|
||||
share_feedback: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub enum ResourceCenterEvent {
|
||||
Close,
|
||||
Escape,
|
||||
}
|
||||
|
||||
pub struct ResourceCenterView {
|
||||
button_mouse_states: MouseStateHandles,
|
||||
header_dimming_mouse_state: MouseStateHandle,
|
||||
current_view_index: usize,
|
||||
page_views: Vec1<ResourceCenterPageView>,
|
||||
window_id: WindowId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResourceCenterAction {
|
||||
Close,
|
||||
NavigatePage(ResourceCenterPage),
|
||||
FooterItemClick(ResourceCenterFooterItem),
|
||||
}
|
||||
|
||||
impl ResourceCenterView {
|
||||
pub fn new(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
tips_completed: ModelHandle<TipsCompleted>,
|
||||
changelog_model_handle: ModelHandle<ChangelogModel>,
|
||||
) -> Self {
|
||||
let main_view = ResourceCenterPageView {
|
||||
page: ResourceCenterPage::Main,
|
||||
page_view_handle: ResourceCenterViewHandle::Main(Self::build_main_view(
|
||||
ctx,
|
||||
tips_completed,
|
||||
changelog_model_handle,
|
||||
)),
|
||||
};
|
||||
let keybindings_view = ResourceCenterPageView {
|
||||
page: ResourceCenterPage::Keybindings,
|
||||
page_view_handle: ResourceCenterViewHandle::Keybindings(Self::build_keybindings_view(
|
||||
ctx,
|
||||
)),
|
||||
};
|
||||
// Subscribe to window state changes for focus dimming updates
|
||||
let state_handle = WindowManager::handle(ctx);
|
||||
ctx.subscribe_to_model(&state_handle, |_me, _, event, ctx| match &event {
|
||||
StateEvent::ValueChanged { current, previous } => {
|
||||
if WindowManager::did_window_change_focus(ctx.window_id(), current, previous) {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let page_views = vec1![main_view, keybindings_view];
|
||||
|
||||
Self {
|
||||
button_mouse_states: Default::default(),
|
||||
header_dimming_mouse_state: Default::default(),
|
||||
current_view_index: 0,
|
||||
page_views,
|
||||
window_id: ctx.window_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_main_view(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
tips_completed: ModelHandle<TipsCompleted>,
|
||||
changelog_model_handle: ModelHandle<ChangelogModel>,
|
||||
) -> ViewHandle<ResourceCenterMainView> {
|
||||
let main_view = ctx.add_typed_action_view(|ctx| {
|
||||
ResourceCenterMainView::new(ctx, tips_completed.clone(), changelog_model_handle)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&main_view, move |me, _, event, ctx| {
|
||||
me.handle_main_event(event, ctx);
|
||||
});
|
||||
|
||||
main_view
|
||||
}
|
||||
|
||||
fn handle_main_event(&mut self, event: &ResourceCenterMainEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
ResourceCenterMainEvent::Close => {
|
||||
ctx.emit(ResourceCenterEvent::Close);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_keybindings_view(ctx: &mut ViewContext<Self>) -> ViewHandle<KeybindingsView> {
|
||||
let keybindings_view = ctx.add_typed_action_view(KeybindingsView::new);
|
||||
|
||||
ctx.subscribe_to_view(&keybindings_view, move |me, _, event, ctx| {
|
||||
me.handle_keybindings_event(event, ctx);
|
||||
});
|
||||
|
||||
keybindings_view
|
||||
}
|
||||
|
||||
fn handle_keybindings_event(&mut self, event: &KeybindingsEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
KeybindingsEvent::Escape => {
|
||||
ctx.emit(ResourceCenterEvent::Escape);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_current_page(&self) -> ResourceCenterPage {
|
||||
self.page_views
|
||||
.get(self.current_view_index)
|
||||
.map(|x| x.page)
|
||||
.expect("Should have a valid page")
|
||||
}
|
||||
|
||||
fn focus(&self, ctx: &mut ViewContext<Self>) {
|
||||
// Change focus depending on page.
|
||||
let current_page_handle = &self.page_views[self.current_view_index].page_view_handle;
|
||||
|
||||
match current_page_handle {
|
||||
ResourceCenterViewHandle::Main(_) => {
|
||||
// Lets terminal view determine where focus is given.
|
||||
ctx.emit(ResourceCenterEvent::Escape);
|
||||
}
|
||||
ResourceCenterViewHandle::Keybindings(keybindings_view_handle) => {
|
||||
ctx.focus(keybindings_view_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_current_page(&mut self, new_page: ResourceCenterPage, ctx: &mut ViewContext<Self>) {
|
||||
let position = self
|
||||
.page_views
|
||||
.iter()
|
||||
.position(|page_view| page_view.page == new_page);
|
||||
|
||||
if let Some(new_page_index) = position {
|
||||
self.current_view_index = new_page_index;
|
||||
}
|
||||
|
||||
self.focus(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn close(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.notify();
|
||||
ctx.emit(ResourceCenterEvent::Close)
|
||||
}
|
||||
|
||||
pub fn set_action_target(
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
input_id: Option<EntityId>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let ResourceCenterViewHandle::Main(main_handle) = &self.page_views[0].page_view_handle {
|
||||
main_handle.update(ctx, |main_view, ctx| {
|
||||
main_view.set_action_target(window_id, input_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_item_click_action(
|
||||
&mut self,
|
||||
item: &ResourceCenterFooterItem,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match item {
|
||||
ResourceCenterFooterItem::Docs => ctx.open_url(links::USER_DOCS_URL),
|
||||
ResourceCenterFooterItem::Slack => ctx.open_url(links::SLACK_URL),
|
||||
// Route feedback through the workspace action so the guided agent experience is
|
||||
// launched when AI is available, and the GitHub issue form is opened otherwise.
|
||||
ResourceCenterFooterItem::Feedback => {
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SendFeedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_back_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
icon_button(
|
||||
appearance,
|
||||
crate::ui_components::icons::Icon::ChevronLeft,
|
||||
false,
|
||||
self.button_mouse_states.navigate_back.clone(),
|
||||
)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ResourceCenterAction::NavigatePage(ResourceCenterPage::Main))
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_keyboard_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.animated_button(
|
||||
self.button_mouse_states.open_keybindings.clone(),
|
||||
icons::Icon::Keyboard.into(),
|
||||
AnimatedButtonOptions {
|
||||
size: KEYBOARD_ICON_SIZE,
|
||||
padding: Some(ICON_PADDING),
|
||||
color: Some(appearance.theme().active_ui_text_color().with_opacity(80)),
|
||||
with_accent_animations: false,
|
||||
circular: false,
|
||||
},
|
||||
)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ResourceCenterAction::NavigatePage(
|
||||
ResourceCenterPage::Keybindings,
|
||||
))
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(ICON_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
SavePosition::new(
|
||||
icon_button(
|
||||
appearance,
|
||||
crate::ui_components::icons::Icon::X,
|
||||
false,
|
||||
self.button_mouse_states.close.clone(),
|
||||
)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(WorkspaceAction::ToggleResourceCenter))
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
"resource_center_close_button",
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_header_contents(&self, appearance: &Appearance) -> Vec<Box<dyn Element>> {
|
||||
let current_page = self.page_views.get(self.current_view_index).map(|x| x.page);
|
||||
|
||||
let header_text = match current_page {
|
||||
Some(ResourceCenterPage::Keybindings) => "Keyboard Shortcuts".to_string(),
|
||||
_ => {
|
||||
if FeatureFlag::AvatarInTabBar.is_enabled() {
|
||||
String::new()
|
||||
} else {
|
||||
"Warp Essentials".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
let title = Shrinkable::new(
|
||||
1.0,
|
||||
Align::new(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(header_text, false)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(HEADER_FONT_SIZE),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(6.)
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Render header items based on page
|
||||
let close_button = self.render_close_button(appearance);
|
||||
match current_page {
|
||||
Some(ResourceCenterPage::Keybindings) => {
|
||||
vec![self.render_back_button(appearance), title, close_button]
|
||||
}
|
||||
_ => {
|
||||
vec![title, self.render_keyboard_button(appearance), close_button]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
const HEADER_VERTICAL_PADDING: f32 = 5.;
|
||||
const HEADER_HORIZONTAL_PADDING: f32 = 6.;
|
||||
let header_body = self.render_header_contents(appearance);
|
||||
|
||||
let header_element = ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_children(header_body)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(HEADER_HORIZONTAL_PADDING)
|
||||
.with_padding_right(HEADER_HORIZONTAL_PADDING)
|
||||
.with_padding_top(HEADER_VERTICAL_PADDING)
|
||||
.with_padding_bottom(HEADER_VERTICAL_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(PANEL_HEADER_HEIGHT)
|
||||
.finish();
|
||||
|
||||
// Apply dimming if window is not focused
|
||||
WindowFocusDimming::apply_panel_header_dimming(
|
||||
header_element,
|
||||
self.header_dimming_mouse_state.clone(),
|
||||
PANEL_HEADER_HEIGHT,
|
||||
appearance.theme().surface_1().into(),
|
||||
self.window_id,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_footer_button(
|
||||
&self,
|
||||
item: ResourceCenterFooterItem,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mouse_state = match item {
|
||||
ResourceCenterFooterItem::Docs => self.button_mouse_states.view_user_docs.clone(),
|
||||
ResourceCenterFooterItem::Slack => self.button_mouse_states.join_slack.clone(),
|
||||
ResourceCenterFooterItem::Feedback => self.button_mouse_states.share_feedback.clone(),
|
||||
};
|
||||
|
||||
let icon = ConstrainedBox::new(
|
||||
Icon::new(
|
||||
item.svg_path(),
|
||||
appearance.theme().active_ui_detail().into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(FOOTER_ICON_SIZE)
|
||||
.with_width(FOOTER_ICON_SIZE);
|
||||
|
||||
let button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, mouse_state)
|
||||
.with_text_label(item.ui_label().to_string())
|
||||
.with_style(
|
||||
UiComponentStyles::default().set_padding(Coords::default().left(SCROLLBAR_OFFSET)),
|
||||
)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ResourceCenterAction::FooterItemClick(item));
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_child(Align::new(icon.finish()).finish())
|
||||
.with_child(button)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_footer(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let docs_button = self.render_footer_button(ResourceCenterFooterItem::Docs, appearance);
|
||||
let slack_button = self.render_footer_button(ResourceCenterFooterItem::Slack, appearance);
|
||||
let feedback_button =
|
||||
self.render_footer_button(ResourceCenterFooterItem::Feedback, appearance);
|
||||
|
||||
let footer = Flex::row()
|
||||
.with_child(docs_button)
|
||||
.with_child(slack_button)
|
||||
.with_child(feedback_button)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceEvenly)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish();
|
||||
|
||||
Container::new(footer)
|
||||
.with_padding_top(SECTION_SPACING)
|
||||
.with_padding_bottom(SECTION_SPACING)
|
||||
.with_border(Border::top(1.).with_border_fill(appearance.theme().surface_2()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ResourceCenterView {
|
||||
type Event = ResourceCenterEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for ResourceCenterView {
|
||||
type Action = ResourceCenterAction;
|
||||
|
||||
fn handle_action(&mut self, action: &ResourceCenterAction, ctx: &mut ViewContext<Self>) {
|
||||
use ResourceCenterAction::*;
|
||||
match action {
|
||||
Close => self.close(ctx),
|
||||
NavigatePage(new_page) => self.set_current_page(*new_page, ctx),
|
||||
FooterItemClick(item) => self.footer_item_click_action(item, ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for ResourceCenterView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ResourceCenter"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
self.focus(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let header = self.render_header(appearance, app);
|
||||
let footer = self.render_footer(appearance);
|
||||
let resource_center_page = &self.page_views[self.current_view_index].page_view_handle;
|
||||
|
||||
let body = match &resource_center_page {
|
||||
ResourceCenterViewHandle::Main(main_view_handle) => {
|
||||
ChildView::new(main_view_handle).finish()
|
||||
}
|
||||
ResourceCenterViewHandle::Keybindings(keybindings_view_handle) => {
|
||||
ChildView::new(keybindings_view_handle).finish()
|
||||
}
|
||||
};
|
||||
|
||||
Flex::column()
|
||||
.with_child(header)
|
||||
.with_child(Shrinkable::new(1., body).finish())
|
||||
.with_child(footer)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user