#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]// Adding this file level gate as some of the code around editability is not used in WASM yet. use pathfinder_color::ColorU;use warp_editor::editor::NavigationKey;use warp_editor::search::{SearchEvent, Searcher};pub use warpui::AppContext;pub use warpui::accessibility::{AccessibilityContent, WarpA11yRole};use warpui::elements::{ Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Element, Flex, Hoverable, MainAxisAlignment, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Radius, Rect, SavePosition, Shrinkable, Text,};pub use warpui::elements::{ParentElement as _, Stack};pub use warpui::geometry::vector::vec2f;use warpui::keymap::EditableBinding;use warpui::presenter::ChildView;use warpui::ui_components::components::UiComponent;use warpui::{ Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,}; use crate::appearance::Appearance;use crate::editor::{ EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,};use crate::features::FeatureFlag;use crate::send_telemetry_from_ctx;use crate::server::telemetry::{FindOption, TelemetryEvent};use crate::settings::AppEditorSettings;use crate::themes::theme::Fill;use crate::ui_components::blended_colors;use crate::ui_components::icons::Icon;use crate::view_components::action_button::{ActionButton, DisabledSecondaryTheme, SecondaryTheme};use crate::view_components::find::FindDirection; pub const FIND_BAR_WIDTH: f32 = 500.;const ICON_PADDING: f32 = 4.;const HORIZONTAL_ICON_SPACING: f32 = 4.;const ICON_CONTAINER_CORNER_RADIUS: f32 = 4.;pub const FIND_BAR_PADDING: f32 = 4.;const FIND_EDITOR_PADDING: f32 = 6.;pub const FIND_EDITOR_BORDER_RADIUS: f32 = 6.;const FIND_EDITOR_BORDER_WIDTH: f32 = 1.;const FIND_EDITOR_FONT_SIZE: f32 = 12.;const FIND_EDITOR_ROW_SPACING: f32 = 4.; pub const REGEX_TOGGLE_TOOLTIP: &str = "Regex toggle";pub const CASE_SENSITIVE_TOOLTIP: &str = "Case sensitive search";pub const PRESERVE_CASE_TOOLTIP: &str = "Preserve case";pub const FIND_PLACEHOLDER_TEXT: &str = "Find";pub const REPLACE_PLACEHOLDER_TEXT: &str = "Replace"; #[derive(Default)]struct ButtonMouseStates { match_up: MouseStateHandle, match_down: MouseStateHandle, close: MouseStateHandle, toggle_case_sensitivity: MouseStateHandle, toggle_regex_search: MouseStateHandle, toggle_replace_open: MouseStateHandle, toggle_preserve_case: MouseStateHandle,} #[derive(Debug)]pub enum Event { CloseFindBar, Update { query: Option<String> }, NextMatch { direction: FindDirection }, SelectAll, ReplaceSelected, ReplaceAll, VimEnterAndFocusEditor,} pub struct CodeEditorFind { find_editor: ViewHandle<EditorView>, replace_editor: ViewHandle<EditorView>, searcher: ModelHandle<Searcher>, button_mouse_states: ButtonMouseStates, find_editor_mouse_state: MouseStateHandle, /// Save position of the find input, so its bounds can be resolved for mouse events. find_editor_position_id: String, preserve_case_enabled: bool, is_open: bool, is_replace_open: bool, select_all_button: ViewHandle<ActionButton>, replace_all_button: ViewHandle<ActionButton>,} #[derive(Copy, Clone, Debug)]pub enum FindAction { Up, Down, Close, ToggleCaseSensitivity, ToggleRegexSearch, CmdG, CmdShiftG, SelectAll, ToggleReplaceOpen, ReplaceAll, TogglePreserveCase, /// The find input was clicked, so it should become editable and take focus again. FocusFindInput,} pub fn init(app: &mut AppContext) { use warpui::keymap::macros::*; app.register_editable_bindings([ EditableBinding::new( "find:find_next_occurrence", "Find the next occurrence of your search query", FindAction::CmdG, ) .with_context_predicate(id!("CodeEditorFind")) // Both Intellij and VSCode use f3/shift-f3 to navigate find occurrences on windows / linux. // See https://www.jetbrains.com/help/idea/reference-keymap-win-default.html#find_everything. .with_mac_key_binding("cmd-g") .with_linux_or_windows_key_binding("f3"), EditableBinding::new( "find:find_prev_occurrence", "Find the previous occurrence of your search query", FindAction::CmdShiftG, ) .with_context_predicate(id!("CodeEditorFind")) .with_mac_key_binding("cmd-shift-G") .with_linux_or_windows_key_binding("shift-f3"), ])} impl CodeEditorFind { pub fn new(searcher: ModelHandle<Searcher>, ctx: &mut ViewContext<Self>) -> Self { let find_editor = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); let mut editor = EditorView::single_line( SingleLineEditorOptions { text: TextOptions::ui_text(Some(FIND_EDITOR_FONT_SIZE), appearance), select_all_on_focus: true, clear_selections_on_blur: true, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }, ctx, ); editor.set_placeholder_text(FIND_PLACEHOLDER_TEXT, ctx); editor }); let replace_editor = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); let mut replace_editor = EditorView::single_line( SingleLineEditorOptions { text: TextOptions::ui_text(Some(FIND_EDITOR_FONT_SIZE), appearance), select_all_on_focus: true, clear_selections_on_blur: true, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }, ctx, ); replace_editor.set_placeholder_text(REPLACE_PLACEHOLDER_TEXT, ctx); replace_editor }); // Get the line height from the find editor's font metrics let line_height = find_editor .as_ref(ctx) .line_height(ctx.font_cache(), Appearance::as_ref(ctx)); // Total editor height is: // - line_height: The height of a single line of text // - 2 * FIND_EDITOR_PADDING: Padding above and below the text (6px * 2 = 12px) // - 5px: Additional spacing to account for button border width let editor_height = line_height + (2. * FIND_EDITOR_PADDING) + 5.; let select_all_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Select all", SecondaryTheme) .on_click(|ctx| { ctx.dispatch_typed_action(FindAction::SelectAll); }) .with_width(72.) .with_height(editor_height) .with_disabled_theme(DisabledSecondaryTheme) }); let replace_all_button = ctx.add_typed_action_view(|ctx| { let mut button = ActionButton::new("Replace all", SecondaryTheme) .on_click(|ctx| { ctx.dispatch_typed_action(FindAction::ReplaceAll); }) .with_width(72.) .with_height(editor_height) .with_disabled_theme(DisabledSecondaryTheme); button.set_disabled(true, ctx); button }); ctx.subscribe_to_view(&find_editor, move |me, _, event, ctx| { me.handle_find_editor_event(event, ctx); }); ctx.subscribe_to_view(&replace_editor, move |me, _, event, ctx| { me.handle_replace_editor_event(event, ctx); }); ctx.subscribe_to_model(&searcher, |me, _, event, ctx| { // Handle search result updates. The searcher emits three events: // - Updated: Search results have changed, handled here to update the select all button // - InvalidQuery: The search query is invalid (e.g. malformed regex), TODO: show error tooltip // - SelectedResultChanged: The selected match changed, handled by the editor view match event { SearchEvent::Updated => { let has_matches = me.searcher.as_ref(ctx).match_count() > 0; me.select_all_button.update(ctx, |button, ctx| { button.set_disabled(!has_matches, ctx); }); ctx.notify(); } SearchEvent::SelectedResultChanged => { // The selected match index changed (e.g., via Vim n/N), needed to re-render index label. ctx.notify(); } SearchEvent::InvalidQuery => {} } }); Self { find_editor, replace_editor, searcher, button_mouse_states: Default::default(), find_editor_mouse_state: Default::default(), find_editor_position_id: format!("code_editor_find_query_{}", ctx.view_id()), preserve_case_enabled: false, is_open: false, is_replace_open: false, select_all_button, replace_all_button, } } pub fn set_open(&mut self, is_open: bool) { self.is_open = is_open; // Replace menu doesn't persist open state if !is_open { self.is_replace_open = false; } } pub fn is_open(&self) -> bool { self.is_open } pub fn is_preserve_case_enabled(&self) -> bool { self.preserve_case_enabled } pub fn replace_query(&self, ctx: &mut ViewContext<Self>) -> String { self.replace_editor.as_ref(ctx).buffer_text(ctx) } pub fn set_find_query(&self, ctx: &mut ViewContext<Self>, query: &str) { self.find_editor.update(ctx, |editor, ctx| { editor.set_buffer_text(query, ctx); }); } /// Returns true if the find input is currently editable. pub fn is_find_input_editable(&self, app: &AppContext) -> bool { self.find_editor.as_ref(app).can_edit(app) } /// Enable or disable the find input editor's interactivity. pub fn set_find_input_editable(&self, ctx: &mut ViewContext<Self>, is_editable: bool) { self.find_editor.update(ctx, |editor, ctx| { let state = if is_editable { InteractionState::Editable } else { InteractionState::Disabled }; editor.set_interaction_state(state, ctx); }); } /// Makes the find input editable, selects its current query, and focuses it. fn activate_find_input(&mut self, ctx: &mut ViewContext<Self>) { self.find_editor.update(ctx, |editor, ctx| { editor.set_interaction_state(InteractionState::Editable, ctx); editor.select_all(ctx); }); ctx.focus(&self.find_editor); ctx.notify(); } fn handle_find_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) { match event { EditorEvent::Edited(_) => { let query = self.find_editor.as_ref(ctx).buffer_text(ctx); ctx.emit(Event::Update { // If the query is empty, don't search for an empty string - set the query to // `None`. query: (!query.is_empty()).then_some(query), }); self.update_replace_button_state(ctx); self.emit_result_a11y_content(ctx); ctx.notify(); } EditorEvent::Enter => { let vim_enabled = FeatureFlag::VimCodeEditor.is_enabled() && AppEditorSettings::as_ref(ctx).vim_mode_enabled(); if !vim_enabled { self.focus_next_match(FindDirection::Down, ctx); } else { // Vim: treat "enter" as ending the search query entry and shift focus back to the editor self.find_editor.update(ctx, |editor, ctx| { editor.clear_selections(ctx); editor.set_interaction_state(InteractionState::Disabled, ctx); }); ctx.emit(Event::VimEnterAndFocusEditor); } } EditorEvent::ShiftEnter | EditorEvent::AltEnter => { let vim_enabled = FeatureFlag::VimCodeEditor.is_enabled() && AppEditorSettings::as_ref(ctx).vim_mode_enabled(); if !vim_enabled { self.focus_next_match(FindDirection::Up, ctx); } } EditorEvent::Escape => { self.close_find_bar(ctx); } EditorEvent::Navigate(NavigationKey::Tab) => { // If replace editor is currently open and the user presses 'tab', focus on the find editor if self.is_replace_open { ctx.focus(&self.replace_editor); } } _ => {} } } fn handle_replace_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) { match event { EditorEvent::Edited(_) => { self.update_replace_button_state(ctx); } EditorEvent::Enter => { ctx.emit(Event::ReplaceSelected); } EditorEvent::Escape => { self.close_find_bar(ctx); } // If the user is focused on the replace editor and presses 'tab', focus should shift back to the find editor EditorEvent::Navigate(NavigationKey::Tab) => ctx.focus(&self.find_editor), _ => (), } } fn update_replace_button_state(&mut self, ctx: &mut ViewContext<Self>) { // Enable the replace all button only if there is replacement text and at least one match found let (has_replace_query, has_match) = ( !self.replace_query(ctx).is_empty(), self.searcher.as_ref(ctx).match_count() > 0, ); let should_enable_replace = has_replace_query && has_match; self.replace_all_button.update(ctx, |button, ctx| { button.set_disabled(!should_enable_replace, ctx); }); } fn focus_next_match(&mut self, direction: FindDirection, ctx: &mut ViewContext<Self>) { // All of the actual update logic for the selected match goes through the event codepath // but for some reason the logic for updating the match index happens below. ctx.emit(Event::NextMatch { direction }); self.emit_result_a11y_content(ctx); ctx.notify(); } /// Emits the a11y announcement informing about the current match/result. /// Note that it's done outside of the regular action_accessibility_contents flow, /// as `focus_next_match` may have multiple entrypoints (that are not Action). pub fn emit_result_a11y_content(&mut self, ctx: &mut ViewContext<Self>) { let content = if let Some(match_index) = self.searcher.as_ref(ctx).selected_match() { AccessibilityContent::new( format!( "Result {} of {}.", match_index + 1, self.searcher.as_ref(ctx).match_count() ), "Use enter and shift-enter to navigate between matches. Escape to quit.", WarpA11yRole::UserAction, ) } else { AccessibilityContent::new_without_help("No results.", WarpA11yRole::UserAction) }; ctx.emit_a11y_content(content); } /// Emits the a11y announcement informing about replace operation results. /// Called when Enter is pressed in the replace field to provide feedback on the operation. pub fn emit_replace_a11y_content(&mut self, ctx: &mut ViewContext<Self>) { let content = if let Some(match_index) = self.searcher.as_ref(ctx).selected_match() { let remaining_matches = self.searcher.as_ref(ctx).match_count(); AccessibilityContent::new( format!( "Successfully replaced match. Selected match is {match_index} of {remaining_matches}" ), "Continue pressing Enter to replace more matches, or use up/down arrows to navigate.", WarpA11yRole::UserAction, ) } else { AccessibilityContent::new_without_help( "Successfully replaced the last match.", WarpA11yRole::UserAction, ) }; ctx.emit_a11y_content(content); } fn close_find_bar(&mut self, ctx: &mut ViewContext<Self>) { ctx.emit(Event::CloseFindBar); } fn toggle_case_sensitivity(&mut self, ctx: &mut ViewContext<Self>) { let new_case_sensitive = !self.searcher.as_ref(ctx).is_case_sensitive(); self.searcher.update(ctx, |searcher, ctx| { searcher.set_case_sensitive(new_case_sensitive, ctx); }); send_telemetry_from_ctx!( TelemetryEvent::ToggleFindOption { option: FindOption::CaseSensitive, enabled: new_case_sensitive }, ctx ); } fn toggle_regex_search(&mut self, ctx: &mut ViewContext<Self>) { let new_regex_enabled = !self.searcher.as_ref(ctx).is_regex(); self.searcher.update(ctx, |searcher, ctx| { searcher.set_regex(new_regex_enabled, ctx); }); send_telemetry_from_ctx!( TelemetryEvent::ToggleFindOption { option: FindOption::Regex, enabled: new_regex_enabled }, ctx ); } fn render_match_index(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> { // If there is some match index, we add 1 to it since the UI is 1-indexed // (i.e. first match starts at index 1 out of the total number of matches). let index = match self.searcher.as_ref(app).selected_match() { None => 0, Some(idx) => idx + 1, }; let label = format!( "{}/{}", if index > 0 { index.to_string() } else { "?".to_string() }, self.searcher.as_ref(app).match_count() ); Text::new_inline(label, appearance.ui_font_family(), FIND_EDITOR_FONT_SIZE) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )) .finish() } #[allow(clippy::too_many_arguments)] pub fn render_hoverable_icon_in_editor( &self, appearance: &Appearance, icon: Icon, is_selected: bool, mouse_state_handle: MouseStateHandle, on_click_action: FindAction, size: f32, tooltip_text: Option<&str>, right_margin: f32, ) -> Box<dyn Element> { Hoverable::new(mouse_state_handle, |state| { let (border, background) = if is_selected { ( Border::all(1.).with_border_fill(appearance.theme().accent()), appearance.theme().find_bar_button_selection_color(),Lines 1–500 of 1086. Load more
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))] // Adding this file level gate as some of the code around editability is not used in WASM yet. use pathfinder_color::ColorU; use warp_editor::editor::NavigationKey; use warp_editor::search::{SearchEvent, Searcher}; pub use warpui::AppContext; pub use warpui::accessibility::{AccessibilityContent, WarpA11yRole}; use warpui::elements::{ Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Element, Flex, Hoverable, MainAxisAlignment, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Radius, Rect, SavePosition, Shrinkable, Text, }; pub use warpui::elements::{ParentElement as _, Stack}; pub use warpui::geometry::vector::vec2f; use warpui::keymap::EditableBinding; use warpui::presenter::ChildView; use warpui::ui_components::components::UiComponent; use warpui::{ Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use crate::appearance::Appearance; use crate::editor::{ EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions, }; use crate::features::FeatureFlag; use crate::send_telemetry_from_ctx; use crate::server::telemetry::{FindOption, TelemetryEvent}; use crate::settings::AppEditorSettings; use crate::themes::theme::Fill; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ActionButton, DisabledSecondaryTheme, SecondaryTheme}; use crate::view_components::find::FindDirection; pub const FIND_BAR_WIDTH: f32 = 500.; const ICON_PADDING: f32 = 4.; const HORIZONTAL_ICON_SPACING: f32 = 4.; const ICON_CONTAINER_CORNER_RADIUS: f32 = 4.; pub const FIND_BAR_PADDING: f32 = 4.; const FIND_EDITOR_PADDING: f32 = 6.; pub const FIND_EDITOR_BORDER_RADIUS: f32 = 6.; const FIND_EDITOR_BORDER_WIDTH: f32 = 1.; const FIND_EDITOR_FONT_SIZE: f32 = 12.; const FIND_EDITOR_ROW_SPACING: f32 = 4.; pub const REGEX_TOGGLE_TOOLTIP: &str = "Regex toggle"; pub const CASE_SENSITIVE_TOOLTIP: &str = "Case sensitive search"; pub const PRESERVE_CASE_TOOLTIP: &str = "Preserve case"; pub const FIND_PLACEHOLDER_TEXT: &str = "Find"; pub const REPLACE_PLACEHOLDER_TEXT: &str = "Replace"; #[derive(Default)] struct ButtonMouseStates { match_up: MouseStateHandle, match_down: MouseStateHandle, close: MouseStateHandle, toggle_case_sensitivity: MouseStateHandle, toggle_regex_search: MouseStateHandle, toggle_replace_open: MouseStateHandle, toggle_preserve_case: MouseStateHandle, } #[derive(Debug)] pub enum Event { CloseFindBar, Update { query: Option<String> }, NextMatch { direction: FindDirection }, SelectAll, ReplaceSelected, ReplaceAll, VimEnterAndFocusEditor, } pub struct CodeEditorFind { find_editor: ViewHandle<EditorView>, replace_editor: ViewHandle<EditorView>, searcher: ModelHandle<Searcher>, button_mouse_states: ButtonMouseStates, find_editor_mouse_state: MouseStateHandle, /// Save position of the find input, so its bounds can be resolved for mouse events. find_editor_position_id: String, preserve_case_enabled: bool, is_open: bool, is_replace_open: bool, select_all_button: ViewHandle<ActionButton>, replace_all_button: ViewHandle<ActionButton>, } #[derive(Copy, Clone, Debug)] pub enum FindAction { Up, Down, Close, ToggleCaseSensitivity, ToggleRegexSearch, CmdG, CmdShiftG, SelectAll, ToggleReplaceOpen, ReplaceAll, TogglePreserveCase, /// The find input was clicked, so it should become editable and take focus again. FocusFindInput, } pub fn init(app: &mut AppContext) { use warpui::keymap::macros::*; app.register_editable_bindings([ EditableBinding::new( "find:find_next_occurrence", "Find the next occurrence of your search query", FindAction::CmdG, ) .with_context_predicate(id!("CodeEditorFind")) // Both Intellij and VSCode use f3/shift-f3 to navigate find occurrences on windows / linux. // See https://www.jetbrains.com/help/idea/reference-keymap-win-default.html#find_everything. .with_mac_key_binding("cmd-g") .with_linux_or_windows_key_binding("f3"), EditableBinding::new( "find:find_prev_occurrence", "Find the previous occurrence of your search query", FindAction::CmdShiftG, ) .with_context_predicate(id!("CodeEditorFind")) .with_mac_key_binding("cmd-shift-G") .with_linux_or_windows_key_binding("shift-f3"), ]) } impl CodeEditorFind { pub fn new(searcher: ModelHandle<Searcher>, ctx: &mut ViewContext<Self>) -> Self { let find_editor = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); let mut editor = EditorView::single_line( SingleLineEditorOptions { text: TextOptions::ui_text(Some(FIND_EDITOR_FONT_SIZE), appearance), select_all_on_focus: true, clear_selections_on_blur: true, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }, ctx, ); editor.set_placeholder_text(FIND_PLACEHOLDER_TEXT, ctx); editor }); let replace_editor = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); let mut replace_editor = EditorView::single_line( SingleLineEditorOptions { text: TextOptions::ui_text(Some(FIND_EDITOR_FONT_SIZE), appearance), select_all_on_focus: true, clear_selections_on_blur: true, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }, ctx, ); replace_editor.set_placeholder_text(REPLACE_PLACEHOLDER_TEXT, ctx); replace_editor }); // Get the line height from the find editor's font metrics let line_height = find_editor .as_ref(ctx) .line_height(ctx.font_cache(), Appearance::as_ref(ctx)); // Total editor height is: // - line_height: The height of a single line of text // - 2 * FIND_EDITOR_PADDING: Padding above and below the text (6px * 2 = 12px) // - 5px: Additional spacing to account for button border width let editor_height = line_height + (2. * FIND_EDITOR_PADDING) + 5.; let select_all_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Select all", SecondaryTheme) .on_click(|ctx| { ctx.dispatch_typed_action(FindAction::SelectAll); }) .with_width(72.) .with_height(editor_height) .with_disabled_theme(DisabledSecondaryTheme) }); let replace_all_button = ctx.add_typed_action_view(|ctx| { let mut button = ActionButton::new("Replace all", SecondaryTheme) .on_click(|ctx| { ctx.dispatch_typed_action(FindAction::ReplaceAll); }) .with_width(72.) .with_height(editor_height) .with_disabled_theme(DisabledSecondaryTheme); button.set_disabled(true, ctx); button }); ctx.subscribe_to_view(&find_editor, move |me, _, event, ctx| { me.handle_find_editor_event(event, ctx); }); ctx.subscribe_to_view(&replace_editor, move |me, _, event, ctx| { me.handle_replace_editor_event(event, ctx); }); ctx.subscribe_to_model(&searcher, |me, _, event, ctx| { // Handle search result updates. The searcher emits three events: // - Updated: Search results have changed, handled here to update the select all button // - InvalidQuery: The search query is invalid (e.g. malformed regex), TODO: show error tooltip // - SelectedResultChanged: The selected match changed, handled by the editor view match event { SearchEvent::Updated => { let has_matches = me.searcher.as_ref(ctx).match_count() > 0; me.select_all_button.update(ctx, |button, ctx| { button.set_disabled(!has_matches, ctx); }); ctx.notify(); } SearchEvent::SelectedResultChanged => { // The selected match index changed (e.g., via Vim n/N), needed to re-render index label. ctx.notify(); } SearchEvent::InvalidQuery => {} } }); Self { find_editor, replace_editor, searcher, button_mouse_states: Default::default(), find_editor_mouse_state: Default::default(), find_editor_position_id: format!("code_editor_find_query_{}", ctx.view_id()), preserve_case_enabled: false, is_open: false, is_replace_open: false, select_all_button, replace_all_button, } } pub fn set_open(&mut self, is_open: bool) { self.is_open = is_open; // Replace menu doesn't persist open state if !is_open { self.is_replace_open = false; } } pub fn is_open(&self) -> bool { self.is_open } pub fn is_preserve_case_enabled(&self) -> bool { self.preserve_case_enabled } pub fn replace_query(&self, ctx: &mut ViewContext<Self>) -> String { self.replace_editor.as_ref(ctx).buffer_text(ctx) } pub fn set_find_query(&self, ctx: &mut ViewContext<Self>, query: &str) { self.find_editor.update(ctx, |editor, ctx| { editor.set_buffer_text(query, ctx); }); } /// Returns true if the find input is currently editable. pub fn is_find_input_editable(&self, app: &AppContext) -> bool { self.find_editor.as_ref(app).can_edit(app) } /// Enable or disable the find input editor's interactivity. pub fn set_find_input_editable(&self, ctx: &mut ViewContext<Self>, is_editable: bool) { self.find_editor.update(ctx, |editor, ctx| { let state = if is_editable { InteractionState::Editable } else { InteractionState::Disabled }; editor.set_interaction_state(state, ctx); }); } /// Makes the find input editable, selects its current query, and focuses it. fn activate_find_input(&mut self, ctx: &mut ViewContext<Self>) { self.find_editor.update(ctx, |editor, ctx| { editor.set_interaction_state(InteractionState::Editable, ctx); editor.select_all(ctx); }); ctx.focus(&self.find_editor); ctx.notify(); } fn handle_find_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) { match event { EditorEvent::Edited(_) => { let query = self.find_editor.as_ref(ctx).buffer_text(ctx); ctx.emit(Event::Update { // If the query is empty, don't search for an empty string - set the query to // `None`. query: (!query.is_empty()).then_some(query), }); self.update_replace_button_state(ctx); self.emit_result_a11y_content(ctx); ctx.notify(); } EditorEvent::Enter => { let vim_enabled = FeatureFlag::VimCodeEditor.is_enabled() && AppEditorSettings::as_ref(ctx).vim_mode_enabled(); if !vim_enabled { self.focus_next_match(FindDirection::Down, ctx); } else { // Vim: treat "enter" as ending the search query entry and shift focus back to the editor self.find_editor.update(ctx, |editor, ctx| { editor.clear_selections(ctx); editor.set_interaction_state(InteractionState::Disabled, ctx); }); ctx.emit(Event::VimEnterAndFocusEditor); } } EditorEvent::ShiftEnter | EditorEvent::AltEnter => { let vim_enabled = FeatureFlag::VimCodeEditor.is_enabled() && AppEditorSettings::as_ref(ctx).vim_mode_enabled(); if !vim_enabled { self.focus_next_match(FindDirection::Up, ctx); } } EditorEvent::Escape => { self.close_find_bar(ctx); } EditorEvent::Navigate(NavigationKey::Tab) => { // If replace editor is currently open and the user presses 'tab', focus on the find editor if self.is_replace_open { ctx.focus(&self.replace_editor); } } _ => {} } } fn handle_replace_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) { match event { EditorEvent::Edited(_) => { self.update_replace_button_state(ctx); } EditorEvent::Enter => { ctx.emit(Event::ReplaceSelected); } EditorEvent::Escape => { self.close_find_bar(ctx); } // If the user is focused on the replace editor and presses 'tab', focus should shift back to the find editor EditorEvent::Navigate(NavigationKey::Tab) => ctx.focus(&self.find_editor), _ => (), } } fn update_replace_button_state(&mut self, ctx: &mut ViewContext<Self>) { // Enable the replace all button only if there is replacement text and at least one match found let (has_replace_query, has_match) = ( !self.replace_query(ctx).is_empty(), self.searcher.as_ref(ctx).match_count() > 0, ); let should_enable_replace = has_replace_query && has_match; self.replace_all_button.update(ctx, |button, ctx| { button.set_disabled(!should_enable_replace, ctx); }); } fn focus_next_match(&mut self, direction: FindDirection, ctx: &mut ViewContext<Self>) { // All of the actual update logic for the selected match goes through the event codepath // but for some reason the logic for updating the match index happens below. ctx.emit(Event::NextMatch { direction }); self.emit_result_a11y_content(ctx); ctx.notify(); } /// Emits the a11y announcement informing about the current match/result. /// Note that it's done outside of the regular action_accessibility_contents flow, /// as `focus_next_match` may have multiple entrypoints (that are not Action). pub fn emit_result_a11y_content(&mut self, ctx: &mut ViewContext<Self>) { let content = if let Some(match_index) = self.searcher.as_ref(ctx).selected_match() { AccessibilityContent::new( format!( "Result {} of {}.", match_index + 1, self.searcher.as_ref(ctx).match_count() ), "Use enter and shift-enter to navigate between matches. Escape to quit.", WarpA11yRole::UserAction, ) } else { AccessibilityContent::new_without_help("No results.", WarpA11yRole::UserAction) }; ctx.emit_a11y_content(content); } /// Emits the a11y announcement informing about replace operation results. /// Called when Enter is pressed in the replace field to provide feedback on the operation. pub fn emit_replace_a11y_content(&mut self, ctx: &mut ViewContext<Self>) { let content = if let Some(match_index) = self.searcher.as_ref(ctx).selected_match() { let remaining_matches = self.searcher.as_ref(ctx).match_count(); AccessibilityContent::new( format!( "Successfully replaced match. Selected match is {match_index} of {remaining_matches}" ), "Continue pressing Enter to replace more matches, or use up/down arrows to navigate.", WarpA11yRole::UserAction, ) } else { AccessibilityContent::new_without_help( "Successfully replaced the last match.", WarpA11yRole::UserAction, ) }; ctx.emit_a11y_content(content); } fn close_find_bar(&mut self, ctx: &mut ViewContext<Self>) { ctx.emit(Event::CloseFindBar); } fn toggle_case_sensitivity(&mut self, ctx: &mut ViewContext<Self>) { let new_case_sensitive = !self.searcher.as_ref(ctx).is_case_sensitive(); self.searcher.update(ctx, |searcher, ctx| { searcher.set_case_sensitive(new_case_sensitive, ctx); }); send_telemetry_from_ctx!( TelemetryEvent::ToggleFindOption { option: FindOption::CaseSensitive, enabled: new_case_sensitive }, ctx ); } fn toggle_regex_search(&mut self, ctx: &mut ViewContext<Self>) { let new_regex_enabled = !self.searcher.as_ref(ctx).is_regex(); self.searcher.update(ctx, |searcher, ctx| { searcher.set_regex(new_regex_enabled, ctx); }); send_telemetry_from_ctx!( TelemetryEvent::ToggleFindOption { option: FindOption::Regex, enabled: new_regex_enabled }, ctx ); } fn render_match_index(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> { // If there is some match index, we add 1 to it since the UI is 1-indexed // (i.e. first match starts at index 1 out of the total number of matches). let index = match self.searcher.as_ref(app).selected_match() { None => 0, Some(idx) => idx + 1, }; let label = format!( "{}/{}", if index > 0 { index.to_string() } else { "?".to_string() }, self.searcher.as_ref(app).match_count() ); Text::new_inline(label, appearance.ui_font_family(), FIND_EDITOR_FONT_SIZE) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )) .finish() } #[allow(clippy::too_many_arguments)] pub fn render_hoverable_icon_in_editor( &self, appearance: &Appearance, icon: Icon, is_selected: bool, mouse_state_handle: MouseStateHandle, on_click_action: FindAction, size: f32, tooltip_text: Option<&str>, right_margin: f32, ) -> Box<dyn Element> { Hoverable::new(mouse_state_handle, |state| { let (border, background) = if is_selected { ( Border::all(1.).with_border_fill(appearance.theme().accent()), appearance.theme().find_bar_button_selection_color(),