//! This module contains the code for the editable accept autosuggestion keybinding //! shown inline in the input. use galaxy_core::ui::theme::Fill; use lazy_static::lazy_static; use pathfinder_geometry::vector::vec2f; use warpui::elements::{ Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, DEFAULT_UI_LINE_HEIGHT_RATIO, }; use warpui::keymap::Keystroke; use warpui::platform::Cursor; use warpui::ui_components::components::{UiComponent, UiComponentStyles}; use warpui::ui_components::keyboard_shortcut::KeyboardShortcut; use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle}; use super::EditorElement; use crate::appearance::Appearance; use crate::editor::ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME; use crate::menu::{Menu, MenuItemFields}; use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier}; use crate::terminal::input::OPEN_COMPLETIONS_KEYBINDING_NAME; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::util::bindings::{ keybinding_name_to_keystroke, reset_keybinding_to_default, set_custom_keybinding, }; use crate::workspace::WorkspaceAction; pub const AUTOSUGGESTION_HINT_MINIMUM_HEIGHT: f32 = 12.; lazy_static! { static ref TAB_KEYSTROKE: Keystroke = Keystroke { key: "tab".to_string(), ..Default::default() }; static ref CTRL_SPACE_KEYSTROKE: Keystroke = Keystroke { key: " ".into(), ctrl: true, ..Default::default() }; } pub struct AcceptAutosuggestionKeybinding { is_menu_open: bool, select_keybinding_menu: ViewHandle>, autosuggestion_hint_mouse_handle: MouseStateHandle, accept_autosuggestion_keybinding: Option, } pub enum AcceptAutosuggestionKeybindingEvent { SetAcceptAutosuggestionKeybinding {}, OpenSettingsForCustomKeybinding, } impl Entity for AcceptAutosuggestionKeybinding { type Event = AcceptAutosuggestionKeybindingEvent; } impl AcceptAutosuggestionKeybinding { pub fn new(ctx: &mut ViewContext) -> Self { let accept_autosuggestion_keybinding = keybinding_name_to_keystroke(ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME, ctx); let select_keybinding_menu = ctx.add_typed_action_view(|ctx| { Menu::new().with_border( Border::all(1.) .with_border_color(blended_colors::neutral_4(Appearance::as_ref(ctx).theme())), ) }); let right_arrow = Keystroke::parse("right").expect("can parse keystroke"); let tab = Keystroke::parse("tab").expect("can parse keystroke"); let shift_right_arrow = Keystroke::parse("shift-right").expect("can parse keystroke"); let menu_items: Vec> = vec![ MenuItemFields::new(right_arrow.displayed()) .with_on_select_action( AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding { keystroke: right_arrow, }, ) .into_item(), MenuItemFields::new(tab.displayed().to_lowercase()) .with_on_select_action( AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding { keystroke: tab, }, ) .into_item(), MenuItemFields::new(shift_right_arrow.displayed().to_lowercase()) .with_on_select_action( AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding { keystroke: shift_right_arrow, }, ) .into_item(), MenuItemFields::new("Custom...") .with_on_select_action( AcceptAutosuggestionKeybindingAction::OpenSettingsForCustomKeybinding, ) .into_item(), ]; select_keybinding_menu.update(ctx, |menu, ctx| { menu.set_items(menu_items, ctx); }); ctx.subscribe_to_view(&select_keybinding_menu, |me, _, event, ctx| { if let crate::menu::Event::Close { .. } = event { me.close_menu(ctx); } }); // Subscribe to when the accept autosuggestion keybinding changes. let notifier = KeybindingChangedNotifier::handle(ctx); ctx.subscribe_to_model(¬ifier, |me, _, event, ctx| match event { KeybindingChangedEvent::BindingChanged { binding_name, new_trigger, } => { if binding_name == ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME { me.accept_autosuggestion_keybinding = new_trigger.clone(); me.update_menu_selected_item(ctx); } } }); let mut me = Self { is_menu_open: false, select_keybinding_menu, autosuggestion_hint_mouse_handle: Default::default(), accept_autosuggestion_keybinding, }; me.update_menu_selected_item(ctx); me } pub fn close_menu(&mut self, ctx: &mut ViewContext) { self.is_menu_open = false; ctx.notify(); } /// Updates the menu selected item based on the current accept autosuggestion keybinding. fn update_menu_selected_item(&mut self, ctx: &mut ViewContext) { let accept_autosuggestion_keybinding_displayed = self .accept_autosuggestion_keybinding .as_ref() .map(|keystroke| keystroke.displayed().to_lowercase()); self.select_keybinding_menu.update(ctx, |menu, ctx| { if let Some(accept_autosuggestion_keybinding_displayed) = accept_autosuggestion_keybinding_displayed { let found = menu.set_selected_by_name(accept_autosuggestion_keybinding_displayed, ctx); // If the keybinding is not one of our default options, select the "Custom..." item. if !found { menu.set_selected_by_name("Custom...", ctx); } } else { // If the keybinding is not set, we show right arrow which always works. menu.set_selected_by_action( &AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding { keystroke: Keystroke::parse("right").expect("can parse keystroke"), }, ctx, ); } }); } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum AcceptAutosuggestionKeybindingAction { SetAcceptAutosuggestionKeybinding { keystroke: Keystroke }, OpenSettingsForCustomKeybinding, OpenMenu, } impl TypedActionView for AcceptAutosuggestionKeybinding { type Action = AcceptAutosuggestionKeybindingAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding { keystroke, } => { // When set to right arrow, we reset to the default (clears keybinding) // so that it's still possible to use right arrow to navigate the input when the cursor isn't at the end of the line. if keystroke.displayed() == "→" { reset_keybinding_to_default(ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME, ctx); } else { // Whatever keybinding is set here will always work regardless of where the cursor is. set_custom_keybinding(ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME, keystroke, ctx); } let open_completions_keystroke = keybinding_name_to_keystroke(OPEN_COMPLETIONS_KEYBINDING_NAME, ctx); // If we're setting accept autosuggestion to tab and that conflicts with open completions, // set open completions to ctrl-space. if *keystroke == *TAB_KEYSTROKE && open_completions_keystroke .as_ref() .is_some_and(|keystroke| *keystroke == *TAB_KEYSTROKE) { set_custom_keybinding( OPEN_COMPLETIONS_KEYBINDING_NAME, &CTRL_SPACE_KEYSTROKE, ctx, ); } else if *keystroke != *TAB_KEYSTROKE && open_completions_keystroke .is_some_and(|keystroke| keystroke == *CTRL_SPACE_KEYSTROKE) { // If we're setting accept autosuggestion to anything other than tab, switch open completions back to tab if it was set to ctrl-space above. // Note that we can't tell if the user explicitly set it to ctrl-space vs we did it as a default above. reset_keybinding_to_default(OPEN_COMPLETIONS_KEYBINDING_NAME, ctx); } } AcceptAutosuggestionKeybindingAction::OpenSettingsForCustomKeybinding => ctx .dispatch_typed_action(&WorkspaceAction::ConfigureKeybindingSettings { keybinding_name: Some("Accept Autosuggestion".to_owned()), }), AcceptAutosuggestionKeybindingAction::OpenMenu => { self.is_menu_open = true; } }; ctx.notify(); } } impl View for AcceptAutosuggestionKeybinding { fn ui_name() -> &'static str { "AcceptAutosuggestionKeybinding" } fn render(&self, ctx: &AppContext) -> Box { let appearance = Appearance::as_ref(ctx); // Because the icon's origin is NOT the top of the line but the top of the cursor, // we should render it with line height ratio no larger than DEFAULT_UI_LINE_HEIGHT_RATIO. // With larger line height ratios, there wouldn't be enough space in the icon to render the text. // But we do need to account for smaller line height ratios so the text can be rendered in the smaller space. let line_height_ratio = appearance .line_height_ratio() .min(DEFAULT_UI_LINE_HEIGHT_RATIO); // We want the keybinding icon to be the same height as the cursor in the input. let height = EditorElement::cursor_height(appearance.monospace_font_size(), line_height_ratio) .max(AUTOSUGGESTION_HINT_MINIMUM_HEIGHT); let disabled_color = blended_colors::semantic_text_disabled(appearance.theme()); // If there's no keybinding set, right arrow always works. let keystroke = self .accept_autosuggestion_keybinding .clone() .unwrap_or(Keystroke::parse("right").expect("can parse keystroke")); let border_width = 1.; // The stack contains the editable keybinding, tooltip that shows upon mouse hover, // and menu on click. let mut stack = Stack::new(); if self.is_menu_open { stack.add_positioned_overlay_child( ChildView::new(&self.select_keybinding_menu).finish(), OffsetPositioning::offset_from_parent( vec2f(0., -1.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ), ); } let is_menu_open = self.is_menu_open; Hoverable::new(self.autosuggestion_hint_mouse_handle.clone(), |state| { // Colors are inverted when hovered or menu is open. let (font_color, background_color) = if is_menu_open || state.is_hovered() { ( appearance.theme().background().into(), Some(blended_colors::semantic_text_disabled(appearance.theme())), ) } else { ( blended_colors::semantic_text_disabled(appearance.theme()), None, ) }; let font_size = appearance.monospace_font_size() - 2.; // We use UI font family for the keyboard shortcut even though it's in the input // because monospace font family is configurable, and might not handle symbols like // modifiers and arrows well. let keystroke = KeyboardShortcut::new( &keystroke, UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_size: Some(font_size), font_color: Some(font_color), ..Default::default() }, ) .lowercase_modifier() .text_only() .with_line_height_ratio(line_height_ratio) .build() .finish(); let height_without_border = height - border_width * 2.; let chevron_down = Container::new( ConstrainedBox::new( Icon::ArrowDropDown .to_galaxyui_icon(Fill::Solid(font_color)) .finish(), ) .with_height(height_without_border) .with_width(height_without_border) .finish(), ) .finish(); let mut editable_keystroke = Container::new( Flex::row() .with_children([keystroke, chevron_down]) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(), ) // Padding on the left for the keyboard shortcut. // The arrow down icon already has its own padding so no need on the right. .with_padding_left(4.) .with_border(Border::all(border_width).with_border_color(disabled_color)) .with_corner_radius(CornerRadius::with_all(Radius::Percentage(25.))); if let Some(background_color) = background_color { editable_keystroke = editable_keystroke.with_background_color(background_color); } let editable_keystroke_element = ConstrainedBox::new(editable_keystroke.finish()) .with_max_height(height) .finish(); stack.add_child(editable_keystroke_element); // Add tooltip on hover. if !is_menu_open && state.is_hovered() { let tool_tip = appearance .ui_builder() .autosuggestion_tool_tip("Change keybinding".into()) .build() .finish(); stack.add_positioned_overlay_child( tool_tip, OffsetPositioning::offset_from_parent( vec2f(0., -4.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ), ); } stack.finish() }) .on_click(|ctx, _, _| { ctx.dispatch_typed_action(AcceptAutosuggestionKeybindingAction::OpenMenu); }) .with_cursor(Cursor::PointingHand) .finish() } }