//! Voltron (https://en.wikipedia.org/wiki/Voltron) in Warp is a code name for the UI element used //! for workflows, ai commands and history search. As @charlespierce pointed out: it's 3 mini lions //! (features) coming together to form one super bot (this view). Hence the name. Don't ask me, I //! haven't watched it. //! //! //! ██╗░░░██╗░█████╗░██╗░░░░░████████╗██████╗░░█████╗░███╗░░██╗ //! ██║░░░██║██╔══██╗██║░░░░░╚══██╔══╝██╔══██╗██╔══██╗████╗░██║ //! ╚██╗░██╔╝██║░░██║██║░░░░░░░░██║░░░██████╔╝██║░░██║██╔██╗██║ //! ░╚████╔╝░██║░░██║██║░░░░░░░░██║░░░██╔══██╗██║░░██║██║╚████║ //! ░░╚██╔╝░░╚█████╔╝███████╗░░░██║░░░██║░░██║╚█████╔╝██║░╚███║ //! ░░░╚═╝░░░░╚════╝░╚══════╝░░░╚═╝░░░╚═╝░░╚═╝░╚════╝░╚═╝░░╚══╝ //! //! use crate::appearance::Appearance; use crate::editor::EditorView; use crate::editor::{ Event as EditorEvent, PlainTextEditorViewAction, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, }; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::terminal::input::MenuPositioning; use crate::terminal::resizable_data::{ModalType, ResizableData, DEFAULT_VOLTRON_WIDTH}; use crate::util::bindings::{self, CustomAction}; use crate::workflows::categories::CategoriesView; use enclose::enclose; use galaxyui::accessibility::AccessibilityContent; use galaxyui::elements::{ resizable_state_handle, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, Element, EventHandler, Flex, Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Resizable, ResizableStateHandle, Shrinkable, Stack, }; use galaxyui::geometry::vector::vec2f; use galaxyui::keymap::{Context, FixedBinding}; use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}; use galaxyui::ui_components::components::UiComponent; use galaxyui::{ AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use pathfinder_geometry::vector::Vector2F; use std::path::PathBuf; use vec1::Vec1; const DROPDOWN_BUTTON_WIDTH: f32 = 200.; const DROPDOWN_PADDING: f32 = 6.; const DROPDOWN_WIDTH: f32 = 170.; const MAX_WIDTH_BOUND: f32 = 1.; const VOLTRON_RIGHT_PADDING: f32 = 15.; /// Padding between Voltron and the start of the editor const EDITOR_PADDING_LEFT: f32 = 14.; pub fn init(app: &mut AppContext) { use galaxyui::keymap::macros::*; app.register_fixed_bindings([FixedBinding::new( "escape", VoltronAction::Close, id!(Voltron::ui_name()), )]); } /// Container view that implements the logic / UI for the Voltron itself, including colors /// and main view rendering. /// `feature_view_handle` stores the actual feature's implementation (view). #[derive(Clone)] pub struct VoltronFeatureView { pub name: VoltronItem, pub feature_view_handle: VoltronFeatureViewHandle, } /// Container enum for features implemented and added to Voltron. #[derive(Clone)] pub enum VoltronFeatureViewHandle { Workflows(ViewHandle), } /// Enum used to identify the item in Voltron. #[derive(Clone, PartialEq, Eq, Copy, Debug)] pub enum VoltronItem { AiCommands, Workflows, History, } impl VoltronItem { pub fn as_str(&self) -> &'static str { match self { VoltronItem::AiCommands => "A.I. Command Search", VoltronItem::Workflows => "Workflows", VoltronItem::History => "History Search", } } } /// Structure used by the `on_load` method to pass extra metadata to features. #[derive(Clone, Default)] pub struct VoltronMetadata { #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] pub active_session_path_if_local: Option, /// Starting editor text for the buffer within Voltron. pub starting_editor_text: Option, /// The keybinding context for updating shortcut strings in the Voltron menu. pub keymap_context: Context, /// How voltron is positioned relative to the input box pub menu_positioning: MenuPositioning, } /// Trait that each of the views used within Voltron should implement, so that all the events are /// then properly propagated to corresponding views. pub trait VoltronFeatureViewMeta { /// Placeholder text to show in the editor. fn editor_placeholder_text(&self) -> &'static str; /// Voltron captures all the editor events and passes them to the currently focused feature by /// calling this method. Note that it does not call `ctx.notify()`, so it's up to the feature /// to do so if needed. fn handle_editor_event( &mut self, event: &EditorEvent, current_editor_text: &str, ctx: &mut ViewContext, ); /// Method called every time a specific feature is loaded. It passes metadata object. fn on_load(&mut self, _metadata: VoltronMetadata, _ctx: &mut ViewContext) {} /// Returns the custom action related to the given feature. fn custom_action() -> Option; fn close(&mut self, _ctx: &mut ViewContext) {} } /// The actual Voltron view, which stores multiple VoltronFeatureViews and controls which one is /// opened etc. /// TODO remove voltron from the code given we are not using it anymore, and we have universal search instead. pub struct Voltron { /// List of features available in Voltron features: Vec1, /// Index of the currently showcased feature. If set to None - then Voltron is closed. current_feature_idx: usize, editor: ViewHandle, dropdown_mouse_state: MouseStateHandle, dropdown: ViewHandle>, expand_dropdown: bool, /// The last metadata then the view was loaded. metadata: VoltronMetadata, resizable_state_handle: ResizableStateHandle, } impl VoltronFeatureViewHandle { fn custom_action(&self) -> Option { match self { VoltronFeatureViewHandle::Workflows(_) => CategoriesView::custom_action(), } } fn child_view(&self) -> Box { use VoltronFeatureViewHandle::*; match self { Workflows(view_handle) => ChildView::new(view_handle).finish(), } } } impl VoltronFeatureView { pub fn new(name: VoltronItem, feature_view_handle: VoltronFeatureViewHandle) -> Self { Self { name, feature_view_handle, } } } impl Voltron { pub fn new(features: Vec1, ctx: &mut ViewContext) -> Self { let editor = { ctx.add_typed_action_view(|ctx| { let options = SingleLineEditorOptions { propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }; EditorView::single_line(options, ctx) }) }; ctx.subscribe_to_view(&editor, |me, _, event, ctx| { me.handle_editor_event(event, ctx); }); let appearance = Appearance::as_ref(ctx); let theme = appearance.theme().clone(); let dropdown = ctx.add_typed_action_view(|_ctx| { Menu::new() .with_width(DROPDOWN_WIDTH) .with_border(Border::all(1.).with_border_fill(theme.outline())) }); ctx.subscribe_to_view(&dropdown, move |me, _, event, ctx| { me.handle_menu_event(event, ctx); }); let resizable_data_handle = ResizableData::handle(ctx); let resizable_state_handle = match resizable_data_handle .as_ref(ctx) .get_handle(ctx.window_id(), ModalType::VoltronWidth) { Some(handle) => handle, None => { log::error!("Couldn't retrieve voltron resizable state handle."); resizable_state_handle(DEFAULT_VOLTRON_WIDTH) } }; Self { features, current_feature_idx: 0, editor, dropdown_mouse_state: Default::default(), dropdown, expand_dropdown: false, metadata: Default::default(), resizable_state_handle, } } fn handle_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext) { if let MenuEvent::Close { via_select_item: _ } = event { self.close_dropdown(ctx) } } fn close_dropdown(&mut self, ctx: &mut ViewContext) { self.expand_dropdown = false; ctx.notify(); } fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { if let EditorEvent::Escape = event { self.close(ctx); return; } if let Some(current_feature) = self.current_feature() { let current_editor_text = self.editor.as_ref(ctx).buffer_text(ctx); match current_feature.feature_view_handle { VoltronFeatureViewHandle::Workflows(view_handle) => view_handle .update(ctx, |view, ctx| { view.handle_editor_event(event, ¤t_editor_text, ctx) }), } } } fn placeholder(&mut self, ctx: &mut ViewContext) -> Option<&'static str> { if let Some(current_feature) = self.current_feature() { Some(match current_feature.feature_view_handle { VoltronFeatureViewHandle::Workflows(view_handle) => { view_handle.read(ctx, |view, _| view.editor_placeholder_text()) } }) } else { None } } fn load(&mut self, metadata: VoltronMetadata, ctx: &mut ViewContext) { self.metadata = metadata; if let Some(current_feature) = self.current_feature() { if let Some(editor_text) = self.metadata.starting_editor_text.as_ref() { self.editor.update(ctx, |view, ctx| { view.clear_buffer_and_reset_undo_stack(ctx); view.user_initiated_insert( editor_text, PlainTextEditorViewAction::SystemInsert, ctx, ); }); } match current_feature.feature_view_handle { VoltronFeatureViewHandle::Workflows(view_handle) => { view_handle.update(ctx, |view, ctx| view.on_load(self.metadata.clone(), ctx)) } } } } fn current_feature(&self) -> Option { let feature_idx = if self.current_feature_idx >= self.features.len() { 0 } else { self.current_feature_idx }; self.features.get(feature_idx).cloned() } fn feature_index(&self, feature_name: VoltronItem) -> Option { self.features .iter() .position(|feature| feature_name == feature.name) } fn select(&mut self, index: usize, ctx: &mut ViewContext) { self.current_feature_idx = index; let placeholder = self.placeholder(ctx); self.editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); if let Some(placeholder) = placeholder { editor.set_placeholder_text(placeholder, ctx); } }); } /// Selecting a feature by its name. No-op if there's no such feature. pub fn select_and_refresh_by_name( &mut self, feature_name: VoltronItem, metadata: VoltronMetadata, ctx: &mut ViewContext, ) { let idx = self.feature_index(feature_name); match idx { None => { log::info!( "Trying to open {} in Voltron, but no such feature registered", feature_name.as_str() ); // Close voltron as a feature was requested that isn't registered. self.close(ctx); } Some(idx) => { self.select(idx, ctx); self.load(metadata, ctx); ctx.focus_self(); ctx.notify(); } } } fn update_menu_shortcuts(&mut self, ctx: &mut ViewContext) { let features = self.features.clone(); let context = self.metadata.keymap_context.clone(); self.dropdown.update(ctx, move |menu, ctx| { let items: Vec> = features .into_iter() .map(|view| { let item = MenuItemFields::new(view.name.as_str()) .with_on_select_action(VoltronAction::SelectAndRefresh(view.name)); let label = view.feature_view_handle.custom_action().and_then(enclose!( (context) | action | { ctx.binding_for_custom_action(action.into(), vec![context]) .and_then(|binding| bindings::trigger_to_keystroke(binding.trigger)) .map(|keystroke| keystroke.displayed()) } )); item.with_key_shortcut_label(label).into_item() }) .collect(); menu.set_items(items, ctx); }); } /// Closing Voltron if there is a currently selected feature. Otherwise this method is a no-op. pub fn close(&mut self, ctx: &mut ViewContext) { if let Some(current_feature) = self.current_feature() { match current_feature.feature_view_handle { VoltronFeatureViewHandle::Workflows(view_handle) => { view_handle.update(ctx, |view, ctx| view.close(ctx)) } } } self.close_dropdown(ctx); ctx.emit(VoltronEvent::Close); ctx.notify(); } fn render_dropdown(&self, appearance: &Appearance) -> Box { let current_feature = self .current_feature() .expect("Voltron is only rendered when it's visible and the feature exists"); let icon_path = "bundled/svg/chevron-up.svg"; let mut dropdown_stack = Stack::new().with_child( Container::new( ConstrainedBox::new( // Why do we have event handler here wrapped around the button, you may // wonder? Well, it's because dropdown has its own `Dismiss` which also // means it'll close the dropdown on the _mouse down_ of the button, which // caused weird behavior of `ToggleDropdown` action. This way, we ensure // the order of events is correct and we open/close dropdown when needed. EventHandler::new( appearance .ui_builder() .button(ButtonVariant::Outlined, self.dropdown_mouse_state.clone()) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::TextFirst, current_feature.name.as_str().to_string(), Icon::new(icon_path, appearance.theme().active_ui_text_color()), MainAxisSize::Min, MainAxisAlignment::SpaceBetween, vec2f(15., 15.), ) .with_inner_padding(10.), ) // .with_text_label(current_feature.name.as_str().to_string()) .build() .finish(), ) .on_left_mouse_down(|ctx, _, _| { ctx.dispatch_typed_action(VoltronAction::ToggleDropdown); DispatchEventResult::StopPropagation }) .finish(), ) .with_max_width(DROPDOWN_BUTTON_WIDTH) .finish(), ) .finish(), ); if self.expand_dropdown { dropdown_stack.add_positioned_overlay_child( Container::new(ChildView::new(&self.dropdown).finish()) .with_padding_left(-(DROPDOWN_WIDTH - DROPDOWN_BUTTON_WIDTH)) .with_padding_bottom(DROPDOWN_PADDING) .finish(), OffsetPositioning::offset_from_parent( vec2f(0., -3.), ParentOffsetBounds::Unbounded, ParentAnchor::TopRight, ChildAnchor::BottomRight, ), ); } dropdown_stack.finish() } /// Callback for computing width bounds of the voltron modal (min, max) /// Takes window size and returns (min, max) bounds to the resizable element. fn compute_panel_width_bounds(window_bounds: Vector2F) -> (f32, f32) { ( 400., (window_bounds.x() * MAX_WIDTH_BOUND - VOLTRON_RIGHT_PADDING).max(400.), ) } } pub enum VoltronEvent { Close, } impl Entity for Voltron { type Event = VoltronEvent; } impl View for Voltron { fn ui_name() -> &'static str { "Voltron" } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { ctx.focus(&self.editor); ctx.notify(); } } /// Voltron by itself doesn't provide any a11y features. Instead it delegates it to the /// currently selected feature's a11y methods. fn accessibility_contents(&self, ctx: &AppContext) -> Option { if let Some(current_feature) = self.current_feature() { // TODO create a delegate macro rather than having all those matches everywhere match current_feature.feature_view_handle { VoltronFeatureViewHandle::Workflows(view_handle) => { view_handle.as_ref(ctx).accessibility_contents(ctx) } } } else { None } } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let current_feature = self .current_feature() .expect("Voltron is only rendered when it's visible and the feature exists"); let editor = self.editor.as_ref(app); let height = editor.line_height(app.font_cache(), appearance); let editor = ConstrainedBox::new(Clipped::new(ChildView::new(&self.editor).finish()).finish()) .with_height(height) .finish(); let mut editor_row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1., editor).finish()); if self.features.len() > 1 { editor_row.add_child(self.render_dropdown(appearance)); } let editor_container = Container::new( ConstrainedBox::new(editor_row.finish()) .with_min_height(20.) .finish(), ) .with_padding_left(EDITOR_PADDING_LEFT) .with_padding_top(12.) .with_padding_right(12.) .with_padding_bottom(12.) .with_background(theme.surface_1()) .finish(); let feature_view = Shrinkable::new(1., current_feature.feature_view_handle.child_view()).finish(); let voltron_content = match self.metadata.menu_positioning { MenuPositioning::AboveInputBox => Flex::column() .with_child(feature_view) .with_child(editor_container) .finish(), MenuPositioning::BelowInputBox => Flex::column() .with_child(editor_container) .with_child(feature_view) .finish(), }; let container = Container::new( ConstrainedBox::new(voltron_content) .with_max_height(402.) .finish(), ) .with_margin_top(117.) .with_background(theme.surface_2()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_border(Border::all(1.0).with_border_fill(theme.outline())); let resizable = Resizable::new(self.resizable_state_handle.clone(), container.finish()) .on_resize(move |ctx, _| ctx.notify()) .with_bounds_callback(Box::new(Self::compute_panel_width_bounds)) .finish(); Dismiss::new(resizable) .on_dismiss(|ctx, _app| ctx.dispatch_typed_action(VoltronAction::Close)) .finish() } } #[derive(Debug, Clone)] pub enum VoltronAction { Close, SelectAndRefresh(VoltronItem), ToggleDropdown, } impl TypedActionView for Voltron { type Action = VoltronAction; fn handle_action(&mut self, action: &VoltronAction, ctx: &mut ViewContext) { match action { VoltronAction::Close => { self.close(ctx); } VoltronAction::SelectAndRefresh(name) => { self.select_and_refresh_by_name(*name, self.metadata.clone(), ctx); self.expand_dropdown = false; ctx.notify(); } VoltronAction::ToggleDropdown => { self.expand_dropdown = !self.expand_dropdown; if self.expand_dropdown { self.update_menu_shortcuts(ctx); } ctx.notify(); } } } }