use std::collections::HashMap; use std::ops::Range; use std::sync::{Arc, Mutex}; use std::time::Duration; use std::{cmp, mem}; use galaxy_core::features::FeatureFlag; use galaxy_core::ui::appearance::DEFAULT_UI_FONT_SIZE; use instant::Instant; use itertools::Itertools; use pathfinder_geometry::rect::RectF; use pathfinder_geometry::vector::{vec2f, Vector2F}; use smallvec::SmallVec; use vim::vim::{MotionType, VimMode}; use warp_util::user_input::UserInput; use warpui::elements::{ AfterLayoutContext, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Event, EventContext, Flex, LayoutContext, PaintContext, ParentElement, Point, Radius, SizeConstraint, Text, DEFAULT_UI_LINE_HEIGHT_RATIO, }; use warpui::event::{DispatchedEvent, KeyState, ModifiersState}; use warpui::keymap::Keystroke; use warpui::platform::keyboard::KeyCode; use warpui::text_layout::{ self, ComputeBaselinePositionArgs, LayoutCache, DEFAULT_TOP_BOTTOM_RATIO, }; use warpui::text_selection_utils::{ calculate_tick_width, create_newline_tick_rect, selection_crosses_newline_row_based, NewlineTickParams, }; use warpui::ui_components::components::UiComponent; use warpui::{AppContext, SingletonEntity, TaskId, ViewHandle}; use super::super::soft_wrap::{ ClampDirection, DisplayPointAndClampDirection, FrameLayouts, SoftWrapPoint, SoftWrapState, }; use super::model::MarkedTextState; use super::snapshot::{ViewSnapshot, VOICE_INPUT_ICON_CURSOR_GAP}; use super::{ position_id_for_cached_point, position_id_for_cursor, CursorColors, DisplayPoint, DrawableSelection, EditorAction, LocalDrawableSelectionData, ReplicaId, ScrollState, SelectAction, }; use crate::appearance::Appearance; use crate::editor::accept_autosuggestion_keybinding_view::{ AcceptAutosuggestionKeybinding, AUTOSUGGESTION_HINT_MINIMUM_HEIGHT, }; use crate::editor::autosuggestion_ignore_view::AutosuggestionIgnore; use crate::editor::position_id_for_first_cursor; use crate::editor::view::AutosuggestionLocation; use crate::settings::CursorDisplayType; use crate::themes::theme::Fill; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; // Similar to the terminal::model::ansi::CursorShape, this Editor Element has different cursor // shapes. However, this element doesn't implement all the same variants, so we don't share that // enum. /// Width for cursor analogous to CursorShape::Beam. const BEAM_CURSOR_WIDTH_PX: f32 = 3.; /// Width for remote cursor is smaller than regular beam cursor to differentiate /// between local and remote cursors. const REMOTE_BEAM_CURSOR_WIDTH_PX: f32 = 2.; /// Width for cursor analogous to CursorShape::Block. const DEFAULT_BLOCK_CURSOR_WIDTH_PX: f32 = 8.; const COMMAND_X_RAY_BOTTOM_PADDING_PX: f32 = 5.; const COMMAND_X_RAY_HOVER_THRESHOLD_PX: f32 = 3.; const COMMAND_X_RAY_HOVER_DELAY: Duration = Duration::from_millis(500); const AUTOSUGGESTION_HINT_PADDING: f32 = 10.; #[derive(Clone, Debug)] pub struct CommandXRayMouseState { // The point at which the hover originated, in pixels pub hover_point: Vector2F, /// Whether the x-ray tooltip is visible pub visible: bool, /// The instant at which the x-ray should show pub hover_at: Instant, /// The timer id for the x-ray, in case we need to cancel it pub timer_id: TaskId, /// Whether the user has dismissed the hover through some action (e.g. esc or cmd-i or typing /// or scrolling) pub user_dismissed: bool, } pub type CommandXRayMouseStateHandle = Arc>>; // Defined separately from LayoutState since LayoutState in used in broader // methods such as paint_lines where we use the final computed line_height. // In the future, we may add properties such as font kerning to this struct. pub struct LineParameters { line_height: f32, cursor_height: f32, } /// Colors to use when rendering text in the editor. #[derive(Clone, Debug)] pub struct TextColors { /// The color to use for regular text. pub default_color: Fill, /// The color to use when the editor is disabled. pub disabled_color: Fill, /// The color to use for suggestions/hints. pub hint_color: Fill, } impl TextColors { /// Default text colors based on the theme. pub fn from_appearance(appearance: &Appearance) -> Self { let theme = appearance.theme(); Self { default_color: theme.main_text_color(theme.background()), disabled_color: theme.disabled_text_color(theme.background()), hint_color: theme.hint_text_color(theme.background()), } } pub fn all_hint_color(appearance: &Appearance) -> Self { let theme = appearance.theme(); let hint_color = theme.hint_text_color(theme.background()); Self { default_color: hint_color, disabled_color: hint_color, hint_color, } } } #[derive(Default)] pub struct EditorDecoratorElements { /// Arbitrary element that renders above the editor element. Currently used /// for the top n-1 lines of the lprompt. pub top_section: Option>, /// The left notch is an arbitrary element that is rendering at the /// top-left of the editor element. Note that we require this to be exactly /// 1 line high. The current use case for this is same-line prompt (this /// notch contains the nth, i.e. last, line of the lprompt). /// We start text painting right after this notch! pub left_notch: Option>, // Similarly, the right notch is currently used for the rprompt in the top-right, // but it could be any arbitrary element. pub right_notch: Option>, // The offset (in px) the right notch should be drawn, from the top-left of // the EditorElement (which is the top of the top section, if that exists). // If not specified, the default behavior is to draw the right notch at the right edge. pub right_notch_offset_px: Option, } /// Structure holds the data necessary to draw cursors. /// The origin and width of the cursor vary based on its position in the buffer. struct CursorData { origin: Vector2F, /// The block cursor is used in Vim's normal mode and visual mode. The cursor should take on /// the exact width of the glyph it's on, so it may vary per-cursor. block_cursor_width: f32, color: Fill, /// Used to map the cursor data to its corresponding selections. replica_id: ReplicaId, } /// This type holds additional information about how to draw a remote peer's /// selections and cursors, specifically within the editor element. pub struct RemoteDrawableSelectionData { pub colors: CursorColors, pub should_draw_cursors: bool, // In contrast to [`super:: RemoteDrawableSelectionData`], the avatar is converted to an element to ensure it can be laid out. pub avatar: Box, } impl From for RemoteDrawableSelectionData { fn from(value: super::RemoteDrawableSelectionData) -> Self { Self { colors: value.colors, should_draw_cursors: value.should_draw_cursors, avatar: value.avatar.build().finish(), } } } /// Element that represents a text editor. pub struct EditorElement { view_snapshot: ViewSnapshot, scroll_state: ScrollState, layout: Option, paint: Option, mouse_state: CommandXRayMouseStateHandle, preferred_cursor_type: CursorDisplayType, soft_wrap: bool, /// Whether the placeholder text should soft wrap placeholder_soft_wrap: bool, /// Even when soft wrapping is off, we still use information about the /// laid out text in our code (the same logic just happens to work with /// or without soft wrapping). soft_wrap_state: SoftWrapState, autosuggestion_shortcut_icon: Option>, autosuggestion_ignore_icon: Option>, cycle_next_command_hint: Option>, vim_mode: Option, text_colors: TextColors, editor_decorator_elements: EditorDecoratorElements, local_selection_data: LocalDrawableSelectionData, remote_selections_data: HashMap, voice_input_cursor_icon: Option>, #[cfg_attr(not(feature = "voice_input"), allow(unused))] voice_input_toggle_key_code: Option, } impl EditorElement { /// Creates a new editor element. #[allow(clippy::too_many_arguments)] pub(super) fn new( view_snapshot: ViewSnapshot, scroll_state: ScrollState, mouse_state: CommandXRayMouseStateHandle, soft_wrap: bool, soft_wrap_state: SoftWrapState, placeholder_soft_wrap: bool, vim_mode: Option, text_colors: TextColors, editor_decorator_elements: EditorDecoratorElements, local_selection_data: LocalDrawableSelectionData, remote_selections_data: HashMap, cursor_display_type: Option, voice_input_toggle_key_code: Option, ) -> Self { let remote_selections_data = HashMap::from_iter(remote_selections_data.into_iter().map( |(replica_id, drawable_selections_data)| { ( replica_id, RemoteDrawableSelectionData::from(drawable_selections_data), ) }, )); Self { view_snapshot, scroll_state, layout: None, paint: None, mouse_state, soft_wrap, placeholder_soft_wrap, soft_wrap_state, autosuggestion_shortcut_icon: None, autosuggestion_ignore_icon: None, vim_mode, text_colors, editor_decorator_elements, local_selection_data, remote_selections_data, preferred_cursor_type: cursor_display_type.unwrap_or_default(), cycle_next_command_hint: None, voice_input_cursor_icon: None, voice_input_toggle_key_code, } } /// Returns whether or not a given replica id is a local peer. fn is_local_replica(&self, replica_id: &ReplicaId, app: &AppContext) -> bool { replica_id == &self.view_snapshot.editor_model.as_ref(app).replica_id(app) } fn scroll_position(&self) -> Vector2F { *self.scroll_state.scroll_position.lock() } /// Handles selection actions. fn mouse_down( &self, position: Vector2F, modifiers: &ModifiersState, is_first_mouse: bool, ctx: &mut EventContext, app: &AppContext, ) -> bool { if !self.view_snapshot.can_select { return false; } let layout = self.layout.as_ref().unwrap(); let paint = self.paint.as_ref().unwrap(); if paint.rect.contains_point(position) { ctx.dispatch_typed_action(EditorAction::Focus); ctx.dispatch_typed_action(EditorAction::ClearParentSelections); // On mobile WASM, request the soft keyboard when tapping on an editable editor. #[cfg(target_family = "wasm")] if self.view_snapshot.editor_model.as_ref(app).can_edit() { ctx.request_soft_keyboard(); } if is_first_mouse { // If the editor is receiving the first mouse click on activation // we want to focus the editor but avoid starting any selections. // No mouse up event is sent for the first mouse click, so we would // otherwise get stuck in a selecting state. return true; } let point_for_position = paint.possible_point_for_position( &self.view_snapshot, self.scroll_position(), layout, position, ); if modifiers.shift { ctx.dispatch_typed_action(EditorAction::Select(SelectAction::Extend { position: point_for_position.display_point, scroll_position: paint.scroll_position( &self.view_snapshot, &self.scroll_state, layout, position, ctx, app, ), })); } else { let position = DisplayPointAndClampDirection { point: point_for_position.display_point, clamp_direction: point_for_position.clamp_direction, }; ctx.dispatch_typed_action(EditorAction::Select(SelectAction::Begin { position, add: if cfg!(target_os = "macos") { modifiers.cmd } else { modifiers.alt }, })); } true } else { false } } /// Handles selection actions. fn double_mouse_down(&self, position: Vector2F, ctx: &mut EventContext) -> bool { if !self.view_snapshot.can_select { return false; } let layout = self.layout.as_ref().unwrap(); let paint = self.paint.as_ref().unwrap(); if paint.rect.contains_point(position) { let position = paint.point_for_position( &self.view_snapshot, self.scroll_position(), layout, position, ); ctx.dispatch_typed_action(EditorAction::SelectWord(position)); true } else { false } } /// Handles selection actions. fn triple_mouse_down(&self, position: Vector2F, ctx: &mut EventContext) -> bool { if !self.view_snapshot.can_select { return false; } let layout = self.layout.as_ref().unwrap(); let paint = self.paint.as_ref().unwrap(); if paint.rect.contains_point(position) { let position = paint.point_for_position( &self.view_snapshot, self.scroll_position(), layout, position, ); ctx.dispatch_typed_action(EditorAction::SelectLine(position)); true } else { false } } fn mouse_up(&self, _position: Vector2F, ctx: &mut EventContext, app: &AppContext) -> bool { if self.view_snapshot.is_selecting(app) { ctx.dispatch_typed_action(EditorAction::Select(SelectAction::End)); true } else { false } } /// Handles selection actions. fn mouse_dragged(&self, position: Vector2F, ctx: &mut EventContext, app: &AppContext) -> bool { let layout = self.layout.as_ref().unwrap(); let paint = self.paint.as_ref().unwrap(); if self.view_snapshot.is_selecting(app) { ctx.dispatch_typed_action(EditorAction::Select(SelectAction::Update { position: paint.point_for_position( &self.view_snapshot, self.scroll_position(), layout, position, ), scroll_position: paint.scroll_position( &self.view_snapshot, &self.scroll_state, layout, position, ctx, app, ), })); true } else { false } } fn key_down(&self, keystroke: &Keystroke, ctx: &mut EventContext) -> bool { if self.view_snapshot.is_focused && (keystroke.cmd || keystroke.ctrl) { // Ctrl and cmd should be handled via key bindings ctx.dispatch_typed_action(EditorAction::UnhandledModifierKey(Arc::new( keystroke.normalized(), ))); } false } fn modifier_key_change( &self, key_code: &KeyCode, state: &KeyState, ctx: &mut EventContext, ) -> bool { cfg_if::cfg_if! { if #[cfg(feature = "voice_input")] { self.maybe_handle_voice_toggle(key_code, state, ctx); } else { // Silence unused param warnings when voice_input is disabled. let _ = (key_code, state, ctx); } } false } #[cfg(feature = "voice_input")] fn maybe_handle_voice_toggle( &self, key_code: &KeyCode, state: &KeyState, ctx: &mut EventContext, ) { if let Some(voice_input_toggle_key_code) = self.voice_input_toggle_key_code { if *key_code == voice_input_toggle_key_code { ctx.dispatch_typed_action(EditorAction::ToggleVoiceInput( voice_input::VoiceInputToggledFrom::Key { state: *state }, )); } } } fn mouse_moved( &mut self, position: Vector2F, ctx: &mut EventContext, app: &AppContext, ) -> bool { let layout = self .layout .as_ref() .expect("layout should be set at event handling"); let paint = self .paint .as_ref() .expect("paint should be set at event handling"); let mut state_guard = self.mouse_state.lock().expect("mouse state lock"); if let Some(state) = &mut *state_guard { if state.user_dismissed { if (state.hover_point - position).length() < COMMAND_X_RAY_HOVER_THRESHOLD_PX { // Early exit if some user action has caused the x-ray tooltip to be dismissed // and the mouse hasn't moved. return false; } else { return self.reset_x_ray(&mut state_guard, Some(position), ctx); } } } if !paint.rect.contains_point(position) { // Mouse is outside of the editor, so clear any pending x-ray and exit early return self.reset_x_ray(&mut state_guard, None, ctx); } let possible_point = paint.possible_point_for_position( &self.view_snapshot, self.scroll_position(), layout, position, ); if let Some(state) = &mut *state_guard { let within_last_mouse_move_radius = (state.hover_point - position).length() < COMMAND_X_RAY_HOVER_THRESHOLD_PX; let is_within_word_boundary = self.is_position_within_x_ray_token_bounds(&possible_point, app); // Case 1: the command xray tooltip is open. We only want to close it if: // - the cursor is not within the word boundary for the token being described // - and the cursor is more than a radius away from the token // The latter condition ensures that we only close the tooltip when // there is a substantial mouse movement (if the cursor is only slightly // moved, even outside of the word boundary, we still want to keep it open). if state.visible && !is_within_word_boundary && !within_last_mouse_move_radius { return self.reset_x_ray(&mut state_guard, Some(position), ctx); } else if !state.visible { // Case 2: the command xray tooltip is not open yet. We only want to open it if // - enough time has elapsed // - the mouse is still within the same radius as it was before // - the point we are considering isn't a clamped point (since we don't // want to describe the last token if the cursor is actually well past the buffer text) let timer_elapsed = Instant::now() >= state.hover_at; if timer_elapsed && within_last_mouse_move_radius && !possible_point.is_clamped { ctx.dispatch_typed_action(EditorAction::TryToShowXRay( possible_point.display_point, )); ctx.clear_notify_timer(state.timer_id); state.visible = true; return true; } else if !within_last_mouse_move_radius { // Case 3: the command xray tooltip is not open yet. We should reset the // state as long as the mouse has moved more than a radius away since the // last tracked mouse position. return self.reset_x_ray(&mut state_guard, Some(position), ctx); } } } else { // Case 4: No timer set, set a new one return self.reset_x_ray(&mut state_guard, Some(position), ctx); } false } fn middle_mouse_down(&self, position: Vector2F, ctx: &mut EventContext) -> bool { if self .paint .as_ref() .is_some_and(|paint| paint.rect.contains_point(position)) { ctx.dispatch_typed_action(EditorAction::MiddleClickPaste); return true; } false } /// Returns whether the given mouse position is within the bounds of the current /// token being used for command x-ray fn is_position_within_x_ray_token_bounds( &self, point: &PossibleDisplayPoint, app: &AppContext, ) -> bool { if point.is_clamped { return false; } if let Some(description) = &self.view_snapshot.command_xray { let start_byte = description.token.span.start(); let end_byte = description.token.span.end(); let position_offset = self .view_snapshot .byte_offset_at_point(&point.display_point, app); if let Some(position_offset) = position_offset { return position_offset.as_usize() >= start_byte && position_offset.as_usize() < end_byte; } } false } /// Resets the timer and (optional) position for triggering command x-ray fn reset_x_ray( &self, x_ray_state: &mut Option, new_position: Option, ctx: &mut EventContext, ) -> bool { let mut updated = false; if let Some(state) = &mut *x_ray_state { if state.visible { ctx.dispatch_typed_action(EditorAction::HideXRay); updated = true; } ctx.clear_notify_timer(state.timer_id); } if let Some(position) = new_position { let (timer_id, hover_at) = ctx.notify_after(COMMAND_X_RAY_HOVER_DELAY); *x_ray_state = Some(CommandXRayMouseState { visible: false, hover_point: position, hover_at, timer_id, user_dismissed: false, }); } else { *x_ray_state = None; } updated } fn typed_characters(&self, chars: &str, ctx: &mut EventContext) -> bool { if self.view_snapshot.is_focused { if self.vim_mode.is_some() { ctx.dispatch_typed_action(EditorAction::VimUserInsert(UserInput::new(chars))); } else { ctx.dispatch_typed_action(EditorAction::UserInsert(UserInput::new(chars))); } return true; } false } fn set_marked_text( &mut self, marked_text: &str, selected_range: &Range, ctx: &mut EventContext, ) -> bool { if self.view_snapshot.is_focused { ctx.dispatch_typed_action(EditorAction::SetMarkedText { marked_text: UserInput::new(marked_text), selected_range: selected_range.clone(), }); return true; } false } fn clear_marked_text(&mut self, ctx: &mut EventContext) -> bool { if self.view_snapshot.is_focused { ctx.dispatch_typed_action(EditorAction::ClearMarkedText); return true; } false } fn drag_and_drop_file( &self, paths: Vec, location: Vector2F, ctx: &mut EventContext, ) -> bool { if self .paint .as_ref() .is_some_and(|paint| paint.rect.contains_point(location)) && self.view_snapshot.is_focused { let paths = paths.into_iter().map(UserInput::new).collect(); ctx.dispatch_typed_action(EditorAction::DragAndDropFiles(paths)); return true; } false } /// Handles mouse wheel and trackpad events fn scroll( &self, position: Vector2F, delta: Vector2F, precise: bool, ctx: &mut EventContext, app: &AppContext, ) -> bool { let paint = self.paint.as_ref().unwrap(); if !paint.rect.contains_point(position) { return false; } let view_snapshot = &self.view_snapshot; let layout_cache = &ctx.text_layout_cache; let y; let x; if precise { let max_glyph_width = view_snapshot.em_width; let line_height = view_snapshot.line_height; x = (self.scroll_position().x() * max_glyph_width - delta.x()) / max_glyph_width; y = (self.scroll_position().y() * line_height - delta.y()) / line_height; } else { x = self.scroll_position().x() - delta.x(); y = self.scroll_position().y() - delta.y() } let scroll_position = vec2f(x, y).clamp( Vector2F::zero(), self.layout .as_ref() .expect("should have layout") .scroll_max(view_snapshot, layout_cache, app), ); // Don't handle this event if this would be a noop so that a parent element // (such as the WaterFallGapElement) can properly handle scrolling if scroll_position != self.scroll_position() { ctx.dispatch_typed_action(EditorAction::Scroll(scroll_position)); true } else { false } } fn x_ray_position_id(&self) -> String { format!("editor:command_x_ray_{}", self.view_snapshot.view_id) } fn visible_selection_range( selection: &mut Range, visible_start: SoftWrapPoint, visible_end: SoftWrapPoint, ) -> Range { if selection.start > selection.end { mem::swap(&mut selection.start, &mut selection.end) } let visible_selection_start_row = cmp::max(selection.start.row(), visible_start.row()); let visible_selection_end_row = cmp::min(selection.end.row(), visible_end.row()); // If selection.end evaluates to visible_end (column 0), its row is not visible. // So we can skip painting it. if visible_selection_end_row == visible_end.row() && selection.end.column() == 0 { visible_selection_start_row..visible_selection_end_row } else { visible_selection_start_row..visible_selection_end_row + 1 } } #[allow(clippy::too_many_arguments)] fn draw_selection( &self, content_origin: Vector2F, row: u32, first_visible_row: u32, color: Fill, selection: &Range, line_height: f32, line_layout: &text_layout::Line, layout: &LayoutState, ctx: &mut PaintContext, ) { // Text content origin defines where we actually began painting the text, as opposed to content // origin which is the origin of the entire EditorElement. We push over the text content origin // if we're looking at the 0th row by the width of the left notch! let text_content_origin = if row == 0 { content_origin + vec2f(layout.left_notch_layout_width_px, 0.) } else { content_origin }; let start_x = if row == selection.start.row() { line_layout.x_for_index(selection.start.column() as usize) } else { 0. }; let end_x = if row == selection.end.row() { line_layout.x_for_index(selection.end.column() as usize) } else { line_layout.width }; ctx.scene .draw_rect_with_hit_recording(RectF::new( text_content_origin + vec2f(start_x, (row - first_visible_row) as f32 * line_height), vec2f(end_x - start_x, line_height), )) .with_background(color); let max_soft_wrap_row = layout.frame_layouts.num_lines().saturating_sub(1) as u32; let is_last_line = row == max_soft_wrap_row; let selection_crosses_newline = selection_crosses_newline_row_based( row as usize, is_last_line, selection.start.row() as usize, selection.end.row() as usize, selection.end.column() as usize, line_layout.end_index(), ); if selection_crosses_newline { let tick_width = calculate_tick_width(line_layout.font_size); let tick_origin = text_content_origin + vec2f( line_layout.width, (row - first_visible_row) as f32 * line_height, ); ctx.scene .draw_rect_with_hit_recording(create_newline_tick_rect(NewlineTickParams { tick_origin, tick_width, tick_height: line_height, })) .with_background(color); } } /// Returns the height that the cursor should be. /// Note that the cursor's origin is NOT the top of the line - it is the "correct" /// spot at the top of the text. This means the cursor's height should be no larger than /// it would be at DEFAULT_UI_LINE_HEIGHT_RATIO. /// We do need to account for smaller line heights to prevent the cursor from extending too far down. pub fn cursor_height(font_size: f32, line_height_ratio: f32) -> f32 { font_size * DEFAULT_UI_LINE_HEIGHT_RATIO.min(line_height_ratio) } /// Draws cursors and avatars for local and remote peers. fn draw_cursors( cursor_display_type: CursorDisplayType, cursors: SmallVec<[CursorData; 32]>, view_snapshot: &ViewSnapshot, remote_selections_data: &mut HashMap, voice_input_icon: &mut Option>, ctx: &mut PaintContext, app: &AppContext, ) { let cursor_height = Self::cursor_height(view_snapshot.font_size, view_snapshot.line_height_ratio); let cursor_corner_radius = match cursor_display_type { CursorDisplayType::Block | CursorDisplayType::Underline => Radius::Pixels(0.), _ => Radius::Percentage(50.), }; for cursor in cursors { let is_local = cursor.replica_id == view_snapshot.editor_model.as_ref(app).replica_id(app); let cursor_width = match cursor_display_type { CursorDisplayType::Block | CursorDisplayType::Underline => { cursor.block_cursor_width } _ if !is_local => REMOTE_BEAM_CURSOR_WIDTH_PX, _ => BEAM_CURSOR_WIDTH_PX, }; let mut cursor_rect = RectF::new(cursor.origin, vec2f(cursor_width, cursor_height)); if cursor_display_type == CursorDisplayType::Underline { cursor_rect.set_origin_y(cursor_rect.origin_y() + view_snapshot.font_size); }; ctx.scene .draw_rect_with_hit_recording(cursor_rect) .with_background(cursor.color) .with_corner_radius(CornerRadius::with_all(cursor_corner_radius)); // Draw cursor avatars for remote selections if !is_local { if let Some(drawable_selections_data) = remote_selections_data.get_mut(&cursor.replica_id) { // Offset for avatar's x origin is calculated based on avatar's size, border and cursor width. // We include half of the cursor's width to ensure the avatar is centered with the cursor's center // and not just the cursor's x origin. let avatar_size = view_snapshot.cursor_avatar_size() + 2.; let avatar_offset = avatar_size / 2. - cursor_width / 2.; let avatar_origin = vec2f( cursor.origin.x() - avatar_offset, cursor.origin.y() - cursor_height, ); // New layer is started so avatars are rendered over text and prompt ctx.scene.start_layer(galaxyui::ClipBounds::None); drawable_selections_data .avatar .paint(avatar_origin, ctx, app); ctx.scene.stop_layer(); } } if let Some(element) = voice_input_icon { let icon_size = view_snapshot.voice_input_icon_size(); let icon_x_offset = icon_size.x() / 2. - cursor_width / 2.; let icon_origin = vec2f( cursor.origin.x() - icon_x_offset, cursor.origin.y() - icon_size.y() - VOICE_INPUT_ICON_CURSOR_GAP, ); // New layer is started so voice icon is rendered over text and prompt ctx.scene.start_layer(galaxyui::ClipBounds::None); element.paint(icon_origin, ctx, app); ctx.scene.stop_layer(); } } } fn cursor_origin_vertical_adjustment_from_text_content_origin( view_snapshot: &ViewSnapshot, compute_baseline_position_args: ComputeBaselinePositionArgs, ) -> Vector2F { vec2f( 0., // 1. Go down by baseline position (which is 80% of line height due to top bottom ratio). // Note that we do NOT have a particular Run (ie font ID) here, so we ALWAYS use the default Line-style // baseline offset calculation for text selections. (view_snapshot.baseline_position_fn()( compute_baseline_position_args, ) // 2. Go up by font size * 80% (top bottom ratio) * 1.2 (default line height ratio) which // gets us to a good point for the cursor origin (the same exact point in default case). - (view_snapshot.font_size * DEFAULT_UI_LINE_HEIGHT_RATIO * DEFAULT_TOP_BOTTOM_RATIO)) .max(0.0), ) } /// This is a helper method responsible for drawing lines within the editor (split into /// multiple selections). It returns vector of cursor positions (1 for every selection). fn draw_selections_and_get_cursor_data( &self, content_origin: Vector2F, bounds: RectF, layout: &LayoutState, line_parameters: LineParameters, ctx: &mut PaintContext, app: &AppContext, ) -> SmallVec<[CursorData; 32]> { let appearance = Appearance::as_ref(app); let fallback_block_cursor_width = DEFAULT_BLOCK_CURSOR_WIDTH_PX * (appearance.monospace_font_size() / DEFAULT_UI_FONT_SIZE); let view_snapshot = &self.view_snapshot; let marked_text_state = view_snapshot .editor_model .as_ref(app) .buffer(app) .marked_text_state(); let first_visible_row = Self::first_visible_row( self.scroll_position().y(), view_snapshot.line_height, layout.top_section_height_px, ); let scroll_top = self.scroll_position().y() * line_parameters.line_height; let visible_end_row = ((scroll_top + bounds.height()) / line_parameters.line_height).ceil() as u32; let vim_visual_tails = view_snapshot.vim_visual_tails(app).collect_vec(); let mut cursors = SmallVec::<[CursorData; 32]>::new(); // Looping over all selections. for ( i, DrawableSelection { range, clamp_direction, replica_id, }, ) in view_snapshot .all_drawable_selections_intersecting_range( DisplayPoint::new(0, 0)..view_snapshot.max_point(app), app, ) .enumerate() { let start = layout .frame_layouts .to_soft_wrap_point(range.start, clamp_direction); let end = layout .frame_layouts .to_soft_wrap_point(range.end, clamp_direction); let is_local_replica = self.is_local_replica(&replica_id, app); let (should_draw_cursors, colors) = if is_local_replica { let data = &self.local_selection_data; (data.should_draw_cursors, data.colors) } else if let Some(data) = self.remote_selections_data.get(&replica_id) { (data.should_draw_cursors, data.colors) } else { log::warn!("Failed to access selections data with replica id"); continue; }; // Only attempt to draw the selection if both the start and end points exist. // There shouldn't be a case where we don't, but it's better to be safe. if let (Some(start), Some(end)) = (start, end) { let mut selection = start..end; let cursor_position = selection.end; let text_content_origin = if cursor_position.row() == 0 { content_origin + vec2f(layout.left_notch_layout_width_px, 0.) } else { content_origin }; if (first_visible_row..visible_end_row).contains(&cursor_position.row()) { let index = selection.end.row() as usize; let cursor_row_layout = match layout.frame_layouts.get_line(index) { Some(layout) => layout, None => { log::warn!("Attempting to access line {index}, but there are fewer lines in the layout."); continue; } }; let cursor_x_index = match &marked_text_state { MarkedTextState::Active { selected_range } => { // The marked text selected range assumes that the marked text starts at 0. // Adjust the cursor position to match what the IME tells us. let ime_offset = selected_range.end; selection.start.column() as usize + ime_offset } MarkedTextState::Inactive => selection.end.column() as usize, }; // Use baseline position to get to bottom of text line, then subtract the font size to // get to top of text. We have the multipliers of default line height ratio and top bottom ratio // to get to the "correct" spot above the normal characters within a font. // Note that we don't want to start from top of line (don't want // a massive cursor for large line heights). let cursor_origin = text_content_origin + vec2f( 0., // 1. Go down by baseline position (which is 80% of line height due to top bottom ratio). // Note that we do NOT have a particular Run (ie font ID) here, so we ALWAYS use the default Line-style // baseline offset calculation for text selections. (view_snapshot.baseline_position_fn()( ComputeBaselinePositionArgs { font_cache: ctx.font_cache, font_size: cursor_row_layout.font_size, line_height_ratio: cursor_row_layout.line_height_ratio, baseline_ratio: cursor_row_layout.baseline_ratio, ascent: cursor_row_layout.ascent, descent: cursor_row_layout.descent, }, ) // 2. Go up by font size * 80% (top bottom ratio) * 1.2 (default line height ratio) which // gets us to a good point for the cursor origin (the same exact point in default case). - (view_snapshot.font_size * DEFAULT_UI_LINE_HEIGHT_RATIO * DEFAULT_TOP_BOTTOM_RATIO)) .max(0.0), ) + vec2f( cursor_row_layout.x_for_index(cursor_x_index), (selection.end.row() - first_visible_row) as f32 * line_parameters.line_height, ); let block_cursor_width = cursor_row_layout .width_for_index(selection.end.column() as usize) .filter(|value| *value > 0.) .unwrap_or(fallback_block_cursor_width); // `should_draw_cursors` is set differently for local and remote cursors: // * remote cursors are drawn based on their block selections // * local cursors are drawn based on their blink setting, // focus on active window, and interaction state if should_draw_cursors { cursors.push(CursorData { origin: cursor_origin, block_cursor_width, color: colors.cursor, replica_id, }); } // Ensure cursor position is always cached when local if is_local_replica { let cursor_size = vec2f(block_cursor_width, line_parameters.cursor_height); ctx.position_cache.cache_position_indefinitely( position_id_for_cursor(view_snapshot.view_id), RectF::new(cursor_origin, cursor_size), ); if i == 0 { ctx.position_cache.cache_position_indefinitely( position_id_for_first_cursor(view_snapshot.view_id), RectF::new(cursor_origin, cursor_size), ); } } } let visible_start = SoftWrapPoint::new(first_visible_row, 0); let visible_end = SoftWrapPoint::new(visible_end_row, 0); if selection.start != selection.end { let visible_selection_range = Self::visible_selection_range(&mut selection, visible_start, visible_end); for row in visible_selection_range { let Some(line_layout) = layout.frame_layouts.get_line(row as usize) else { continue; }; let selection_to_draw = match &marked_text_state { MarkedTextState::Active { selected_range } if !selected_range.is_empty() => { // This is the case where we have a selected range within marked text. // This selected range needs to be highlighted. // Since we model the marked text itself as a selection in the editor, // we need to generate the "real" selection here. let marked_text_selection_start = SoftWrapPoint::new( selection.start.row(), selection.start.column() + selected_range.start as u32, ); let marked_text_selection_end = SoftWrapPoint::new( selection.start.row(), selection.start.column() + selected_range.end as u32, ); let marked_text_selection = marked_text_selection_start..marked_text_selection_end; Some(marked_text_selection) } MarkedTextState::Inactive => Some(selection.clone()), _ => None, }; if let Some(selection_to_draw) = selection_to_draw { self.draw_selection( content_origin, row, first_visible_row, colors.selection, &selection_to_draw, line_parameters.line_height, line_layout, layout, ctx, ); } } } else if let Some(VimMode::Visual(motion_type)) = self.vim_mode { // If we're in Vim visual mode, render the visual mode selection which isn't // the same as the [`crate::editor::view::Selection`]. let Some(visual_tail) = vim_visual_tails.get(i) else { continue; }; let Some(visual_tail) = layout .frame_layouts .to_soft_wrap_point(*visual_tail, clamp_direction) else { continue; }; let mut visual_range = visual_tail..selection.end; if visual_range.start > visual_range.end { mem::swap(&mut visual_range.start, &mut visual_range.end) } match motion_type { // In charwise visual mode, add 1 to account for the fact that the char // under the block cursor gets included in the selection range. MotionType::Charwise => *visual_range.end.column_mut() += 1, // In linewise visual mode, extend the start and end points to the start // and end of the line respectively. MotionType::Linewise => { // This calculation must be done in "DisplayPoint-space" since visual // line mode doesn't respect soft wrap in Vim. let mut start = layout.frame_layouts.to_display_point(visual_range.start); let mut end = layout.frame_layouts.to_display_point(visual_range.end); // Move the start to the beginning of the line, always column 0. *start.point.column_mut() = 0; // Moving to the line end is the same as setting the column to the // "line length" value. let buffer = self.view_snapshot.editor_model.as_ref(app).buffer(app); if let Ok(len) = buffer.line_len(end.point.row()) { *end.point.column_mut() = len; } // Back to "soft-wrap-space" for the visual range painting. if let Some(start) = layout .frame_layouts .to_soft_wrap_point(start.point, start.clamp_direction) { if let Some(end) = layout .frame_layouts .to_soft_wrap_point(end.point, end.clamp_direction) { visual_range = start..end; } } } } let visible_selection_range = Self::visible_selection_range( &mut visual_range, visible_start, visible_end, ); for row in visible_selection_range { let Some(line_layout) = layout.frame_layouts.get_line(row as usize) else { continue; }; if marked_text_state == MarkedTextState::Inactive { self.draw_selection( content_origin, row, first_visible_row, colors.selection, &visual_range, line_parameters.line_height, line_layout, layout, ctx, ); } } } } } cursors } /// Returns the index of the first visible row, given the current scroll position, taking into /// account the top section element, if relevant. fn first_visible_row( scroll_position_y: f32, line_height: f32, top_section_height_px: f32, ) -> u32 { let top_section_height_lines = top_section_height_px / line_height; if scroll_position_y <= top_section_height_lines { return 0; } // Calculate the scroll position relative to the start of the user typed editor content. let adjusted_scroll_position_y = scroll_position_y - top_section_height_lines; adjusted_scroll_position_y.floor() as u32 } /// Returns the fraction of the scroll position (y) that is drawn "above" the visible content area. fn scroll_position_y_fract( scroll_position_y: f32, line_height: f32, top_section_height_px: f32, ) -> f32 { // We draw the top section until the first visible row is no longer 0. Hence, we need to account for the first // row, which is beside the left notch. if Self::first_visible_row(scroll_position_y, line_height, top_section_height_px) == 0 { return scroll_position_y * line_height; } let top_section_height_lines = top_section_height_px / line_height; let adjusted_scroll_position_y = scroll_position_y - top_section_height_lines; // Otherwise, we're simply drawing the first visible row, which can go slightly outside of the visible area. adjusted_scroll_position_y.fract() * line_height } #[allow(clippy::too_many_arguments)] fn paint_lines( &self, content_origin: Vector2F, layout: &LayoutState, line_height: f32, autosuggestion_soft_wrapped_line_ix: Option, ctx: &mut PaintContext, app: &AppContext, last_autosuggestion_glyph_position: &mut Option, ) { let view_snapshot = &self.view_snapshot; let first_visible_row = Self::first_visible_row( self.scroll_position().y(), line_height, layout.top_section_height_px, ); let x_ray_start = view_snapshot .command_xray .as_ref() .map(|desc| desc.token.span.start()); let x_ray_display_point = x_ray_start .and_then(|start| view_snapshot.display_point_at_byte_offset(&start.into(), app)); if x_ray_start.is_none() { ctx.position_cache .clear_position(self.x_ray_position_id().as_str()); } let color = if view_snapshot.can_select { self.text_colors.default_color } else { self.text_colors.disabled_color } .into(); let baseline_position_fn = view_snapshot.baseline_position_fn(); for (ix, line) in layout.frame_layouts.displayed_lines().enumerate() { // Push over text content origin due to left notch, if the first line is visible. let text_content_origin = if ix == 0 && first_visible_row == 0 { content_origin + vec2f(layout.left_notch_layout_width_px, 0.) } else { content_origin }; let line_origin = text_content_origin + vec2f(0., ix as f32 * line_height); if let Some(point) = x_ray_display_point.filter(|point| point.row() == first_visible_row + ix as u32) { let x = line.x_for_index(point.column() as usize); ctx.position_cache.cache_position_indefinitely( self.x_ray_position_id(), RectF::new( line_origin + vec2f(x, COMMAND_X_RAY_BOTTOM_PADDING_PX), Vector2F::zero(), ), ); } line.paint_with_baseline_position( RectF::from_points( line_origin, self.bounds() .expect("layout() should have been called before paint()") .lower_right(), ), &Default::default(), color, ctx.font_cache, ctx.scene, &baseline_position_fn, ); // Determine if the autosuggestion belongs on this line. // If the location of the autosuggestion is not specified, it belongs at the very end // If the autosuggestion is attached to a location, it belongs on the last TextFrame associated // with the logical line. let paint_autosuggestion_here = match autosuggestion_soft_wrapped_line_ix { None => ix == layout.frame_layouts.displayed_lines().count() - 1, Some(line_ix) => line_ix == ix, }; // Render first line of autosuggestion text, if it exists. If not, render // first line of placeholder text, if it exists. if paint_autosuggestion_here { if let Some(suggestion_line) = layout.placeholder_suggestion_text_line_layouts.first() { let line_origin = line_origin + vec2f(line.width, 0.); suggestion_line.paint_with_baseline_position( RectF::from_points( line_origin, self.bounds() .expect("layout() should have been called before paint()") .lower_right(), ), &Default::default(), self.text_colors.hint_color.into(), ctx.font_cache, ctx.scene, &baseline_position_fn, ); if let Some(pos) = last_autosuggestion_glyph_position { *pos = line_origin + vec2f(suggestion_line.width, 0.); } } } } } /// Computes right notch origin, if it should be drawn. Otherwise, returns None. fn compute_right_notch_origin( &self, layout: &LayoutState, first_visible_row: u32, element_origin: Vector2F, content_origin: Vector2F, last_autosuggestion_glyph_position: Option, ) -> Option { if first_visible_row != 0 { return None; } // Use the right notch offset, to compute the right notch origin, if it exists, // otherwise, default to the right edge of the EditorElement. let right_notch_origin = self .editor_decorator_elements .right_notch_offset_px .map(|right_notch_offset_px| element_origin + right_notch_offset_px) .unwrap_or_else(|| { vec2f( layout.size.x() - layout.right_notch_layout_width_px + content_origin.x(), content_origin.y(), ) }); let first_line_intersects_right_notch = layout .frame_layouts .get_line(0) .map(|first_line| { content_origin.x() + first_line.width + layout.left_notch_layout_width_px > right_notch_origin.x() }) .unwrap_or(false); let autosuggestion_intersects_right_notch = last_autosuggestion_glyph_position .map(|position| { // Autosuggestion must be on the same line as the right notch, to intersect it. position.y() == right_notch_origin.y() && position.x() > right_notch_origin.x() }) .unwrap_or(false); // We only want to paint the right notch, if there is no intersecting first line of text or autosuggestion. (!first_line_intersects_right_notch && !autosuggestion_intersects_right_notch) .then_some(right_notch_origin) } /// Note that we lay out all of the rows instead of only the visible ones. fn layout_text_frames( &self, view_snapshot: &ViewSnapshot, size: &Vector2F, left_notch_layout_width_px: f32, top_section_height_px: f32, ctx: &LayoutContext, app: &AppContext, ) -> anyhow::Result { let line_height = view_snapshot.line_height; let max_width = if self.soft_wrap { size.x() } else { f32::MAX }; let frames = view_snapshot.layout_text_frames( 0..view_snapshot.max_point(app).row() + 1, ctx.text_layout_cache, max_width, left_notch_layout_width_px, app, )?; // Determine a limit for the end line. If the size is infinite, // this is infinite. Otherwise, depending on the scrolling position // and the number of lines that fit in the element, calculate the line index. let end_line = if size.y().is_infinite() { u32::MAX } else { let scroll_top = self.scroll_position().y() * line_height + top_section_height_px; ((scroll_top + size.y()) / line_height).ceil() as u32 }; let start_line = Self::first_visible_row( self.scroll_position().y(), line_height, top_section_height_px, ); Ok(FrameLayouts::new(frames, start_line, end_line)) } fn should_show_cycle_next_command_hint(&self, is_cycling: bool, ctx: &AppContext) -> bool { FeatureFlag::CycleNextCommandSuggestion.is_enabled() && self .view_snapshot .editor_model .as_ref(ctx) .buffer(ctx) .is_empty() && (self.view_snapshot.active_next_command_suggestion() || is_cycling) } /// Adds icons used in the input editor but not other editors. pub fn with_input_editor_icons( mut self, accept_autosuggestion_keybinding: &ViewHandle, autosuggestion_ignore: &ViewHandle, show_autosuggestion_keybinding_hint: bool, show_autosuggestion_ignore_button: bool, is_cycling: bool, ctx: &AppContext, ) -> Self { // Text in the input is cut off at line heights < 1, so it's not possible // to render the keybinding shortcut / ignore button at these small line heights. if self.view_snapshot.is_focused && self.view_snapshot.active_autosuggestion() && self.view_snapshot.line_height_ratio >= 1. { if show_autosuggestion_keybinding_hint { self.autosuggestion_shortcut_icon = Some(ChildView::new(accept_autosuggestion_keybinding).finish()); } if show_autosuggestion_ignore_button && FeatureFlag::AllowIgnoringInputSuggestions.is_enabled() { self.autosuggestion_ignore_icon = Some(ChildView::new(autosuggestion_ignore).finish()); } } // If the input buffer is empty, down arrow cycles suggestions. let cycle_next_command_hint = if self.should_show_cycle_next_command_hint(is_cycling, ctx) { let appearance = Appearance::as_ref(ctx); Some( self.render_cycle_next_command_hint(galaxy_core::ui::theme::Fill::Solid( blended_colors::semantic_text_disabled(appearance.theme()), )), ) } else { None }; Self { cycle_next_command_hint, ..self } } fn render_cycle_next_command_hint(&self, color: Fill) -> Box { let font_size = self.view_snapshot.font_size - 2.; let icon_height = Self::cursor_height(font_size, self.view_snapshot.line_height_ratio); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::End) .with_children([ Container::new( ConstrainedBox::new(Icon::ArrowDown.to_galaxyui_icon(color).finish()) .with_max_height(icon_height) .with_max_width(icon_height) .finish(), ) .with_margin_right(self.view_snapshot.em_width) .finish(), Text::new( "Cycle suggestions", self.view_snapshot.font_family, font_size, ) .with_color(self.text_colors.hint_color.into()) .finish(), ]) .finish() } #[cfg(feature = "voice_input")] pub fn with_voice_input_cursor_icon(self, element: Box) -> Self { // If voice input is not active, don't render the icon. if !self.view_snapshot.voice_input_state.is_active() { return self; } Self { voice_input_cursor_icon: Some(element), ..self } } /// Takes into account whether or not we're in Vim mode to determine the cursor type. fn get_cursor_type(&self) -> CursorDisplayType { self.vim_mode .map(|vim_mode| match vim_mode { VimMode::Normal | VimMode::Visual(_) => CursorDisplayType::Block, VimMode::Replace => CursorDisplayType::Underline, VimMode::Insert => CursorDisplayType::Bar, }) .unwrap_or(self.preferred_cursor_type) } } impl Element for EditorElement { fn layout( &mut self, constraint: SizeConstraint, ctx: &mut LayoutContext, app: &AppContext, ) -> Vector2F { let mut size = constraint.max; let view_snapshot = &self.view_snapshot; let font_cache = app.font_cache(); let line_height = view_snapshot.line_height; let top_section_height_px = self .editor_decorator_elements .top_section .as_mut() .map(|top_section_element: &mut Box| { top_section_element.layout(constraint, ctx, app).y() }) .unwrap_or(0.0); // We require the left notch to be exactly 1 line_height high! We do this // to avoid tracking y-offset logic in the EditorElement (for aligning to // the bottom of the left notch). Hence, we only track the width. let left_notch_layout_width_px = self .editor_decorator_elements .left_notch .as_mut() .map(|left_notch_element| left_notch_element.layout(constraint, ctx, app).x()) .unwrap_or(0.0); let right_notch_layout_width_px = self .editor_decorator_elements .right_notch .as_mut() .map(|right_notch_element| right_notch_element.layout(constraint, ctx, app).x()) .unwrap_or(0.0); let frame_layouts = match self.layout_text_frames( view_snapshot, &size, left_notch_layout_width_px, top_section_height_px, ctx, app, ) { Err(error) => { log::error!("error laying out lines: {error}"); return size; } Ok(layouts) => layouts, }; let last_text_line_width = frame_layouts .last_frame() .and_then(|frame| frame.lines().last().map(|line| line.width)) .unwrap_or(0.); // Choose what to render: placeholder takes precedence over autosuggestion let placeholder_suggestion_text_line_layouts = if let Some(placeholder_text) = view_snapshot .matching_placeholder_text(&view_snapshot.editor_model.as_ref(app).buffer_text(app)) { view_snapshot.layout_placeholder_text( &placeholder_text, last_text_line_width + left_notch_layout_width_px, font_cache, ctx.text_layout_cache, &size, self.placeholder_soft_wrap, ) } else if view_snapshot.active_autosuggestion() { view_snapshot.layout_autosuggestion( last_text_line_width + left_notch_layout_width_px, font_cache, ctx.text_layout_cache, &size, self.soft_wrap, ) } else { vec![] }; // Set the height of the element, in case we don't have enough text to fill // in all the space. if size.y().is_infinite() || view_snapshot.autogrow { let num_lines_total = if view_snapshot.is_empty && view_snapshot.placeholder_text_exists() { placeholder_suggestion_text_line_layouts.len() as u32 } else { let num_lines = frame_layouts .frames() .fold(0, |acc, layout| acc + layout.lines().len() as u32) .max(1); // The first line of the autosuggestion is rendered on the last line of the buffer so // don't include it when calculating the height of the editor. let num_autosuggestion_lines = placeholder_suggestion_text_line_layouts .len() .saturating_sub(1) as u32; num_lines + num_autosuggestion_lines }; // We add 1. to the height because when we paint the text content, we force pixel-alignment of the Editor's content // to avoid rendering artifacts with text. This is done by taking the ceil of the content_origin.y(). // This can increase the required height by up to 1, and we don't know if we're going to need to do this until paint time. let computed_y = num_lines_total as f32 * view_snapshot.line_height + top_section_height_px + 1.; let y_before_shrink_delay = computed_y.clamp(constraint.min.y(), size.y()); size.set_y( self.view_snapshot .get_editor_height_with_shrink_delay(y_before_shrink_delay), ); } if size.x().is_infinite() { unimplemented!("we don't yet handle an infinite width constraint on buffer elements"); } let top_section_height_lines = top_section_height_px / view_snapshot.line_height; let total_lines = frame_layouts .frames() .fold(0, |acc, frame| acc + frame.lines().len()) as f32 + top_section_height_lines; let autoscroll_horizontally = view_snapshot.autoscroll_vertically( &self.scroll_state, total_lines, size.y() / line_height, top_section_height_lines, &frame_layouts, app, ) && !self.soft_wrap; let mut max_visible_line_width = frame_layouts .frames() .map(|x| x.max_width()) .reduce(f32::max) .unwrap_or(0.); max_visible_line_width = max_visible_line_width.max( placeholder_suggestion_text_line_layouts .iter() .map(|line| line.width) .reduce(f32::max) .unwrap_or(0.), ); if let Some(element) = self.cycle_next_command_hint.as_mut() { element.layout(constraint, ctx, app); } let cursor_height = Self::cursor_height(view_snapshot.font_size, view_snapshot.line_height_ratio); // Show the autosuggestion hint & ignore button if there is enough space. if cursor_height >= AUTOSUGGESTION_HINT_MINIMUM_HEIGHT { if let Some(element) = self.autosuggestion_shortcut_icon.as_mut() { element.layout(constraint, ctx, app); } if let Some(element) = self.autosuggestion_ignore_icon.as_mut() { element.layout(constraint, ctx, app); } } else { self.autosuggestion_shortcut_icon = None; self.autosuggestion_ignore_icon = None; } // The first line of the suggestion lines is rendered on the same line as the last line // of the buffer, so update the max width to include it. if let Some(autosuggestion_line) = placeholder_suggestion_text_line_layouts.first() { let line_with_both_text_and_autosuggestion_width = last_text_line_width + autosuggestion_line.width; max_visible_line_width = max_visible_line_width.max(line_with_both_text_and_autosuggestion_width); } // The last line of the autosuggestion may have icons that add to width. if let Some(autosuggestion_line) = placeholder_suggestion_text_line_layouts.last() { let mut width_with_icons = autosuggestion_line.width; if let Some(autosuggestion_shortcut_icon) = self.autosuggestion_shortcut_icon.as_ref() { // Add width for the padding and the icon width. width_with_icons += AUTOSUGGESTION_HINT_PADDING + autosuggestion_shortcut_icon .size() .expect("should have size") .x(); } if let Some(autosuggestion_ignore_icon) = self.autosuggestion_ignore_icon.as_ref() { // Add width for the padding and the ignore icon width. width_with_icons += AUTOSUGGESTION_HINT_PADDING + autosuggestion_ignore_icon .size() .expect("should have size") .x(); } max_visible_line_width = max_visible_line_width.max(width_with_icons); } // Layout all avatars let avatar_size = view_snapshot.cursor_avatar_size() + 2.; // 2. to account for border on both sides for drawable_selections_data in self.remote_selections_data.values_mut() { drawable_selections_data.avatar.layout( SizeConstraint::new( vec2f(avatar_size, avatar_size), vec2f(avatar_size, avatar_size), ), ctx, app, ); } if let Some(element) = self.voice_input_cursor_icon.as_mut() { let voice_input_icon_size = view_snapshot.voice_input_icon_size(); element.layout( SizeConstraint::new(voice_input_icon_size, voice_input_icon_size), ctx, app, ); } self.soft_wrap_state.update(frame_layouts.clone()); self.layout = Some(LayoutState { size, frame_layouts, placeholder_suggestion_text_line_layouts, max_visible_line_width, autoscroll_horizontally, top_section_height_px, left_notch_layout_width_px, right_notch_layout_width_px, }); size } fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) { if let Some(layout) = &self.layout { if let Some(top_section) = &mut self.editor_decorator_elements.top_section { top_section.after_layout(ctx, app); } if let Some(left_notch) = &mut self.editor_decorator_elements.left_notch { left_notch.after_layout(ctx, app); } if let Some(right_notch) = &mut self.editor_decorator_elements.right_notch { right_notch.after_layout(ctx, app); } let view_snapshot = &self.view_snapshot; view_snapshot.clamp_scroll_left( &self.scroll_state, layout .scroll_max(view_snapshot, ctx.text_layout_cache, app) .x(), ); if layout.autoscroll_horizontally { view_snapshot.autoscroll_horizontally( &self.scroll_state, self.scroll_position().y() as u32, layout.size.x(), layout.scroll_width(view_snapshot, ctx.text_layout_cache, app), view_snapshot.em_width, layout .frame_layouts .frames() .flat_map(|frame| frame.lines().first()) .collect(), app, ); } } } // TODO: refactor this paint code to make it more readable. fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) { if let Some(layout) = &self.layout { let bounds = RectF::new(origin, layout.size); self.paint = Some(PaintState { rect: bounds, origin: Point::from_vec2f(origin, ctx.scene.z_index()), }); let view_snapshot = &self.view_snapshot; let cursor_height = Self::cursor_height(view_snapshot.font_size, view_snapshot.line_height_ratio); let line_height = view_snapshot.line_height; // The start of the element (top-left of top section). let element_origin = bounds.origin() - vec2f( self.scroll_position().x() * view_snapshot.em_width, Self::scroll_position_y_fract( self.scroll_position().y(), line_height, layout.top_section_height_px, ), ); let first_visible_row = Self::first_visible_row( self.scroll_position().y(), line_height, layout.top_section_height_px, ); // The start of the "core" content, below the top section. // Note that the Grid is always pixel-aligned to avoid rendering artifacts that cause text to look misaligned. // Similarly, we force pixel-alignment of the Editor's content to avoid rendering artifacts with text. let content_origin; if first_visible_row == 0 { if let Some(top_section_element) = &mut self.editor_decorator_elements.top_section { top_section_element.paint(element_origin, ctx, app); content_origin = vec2f( element_origin.x(), element_origin.y() + layout.top_section_height_px, ) .ceil(); } else { content_origin = element_origin.ceil(); } if let Some(left_notch_element) = &mut self.editor_decorator_elements.left_notch { left_notch_element.paint(content_origin, ctx, app); } } else { content_origin = element_origin.ceil(); } let cursors = self.draw_selections_and_get_cursor_data( content_origin, bounds, layout, LineParameters { line_height, cursor_height, }, ctx, app, ); Self::draw_cursors( self.get_cursor_type(), cursors, view_snapshot, &mut self.remote_selections_data, &mut self.voice_input_cursor_icon, ctx, app, ); // Determine where to place the autosuggestion hint. // It's mutable and updated according to the width of autosuggestion lines. And it is only drawn at the last line of autosuggestion. let mut last_autosuggestion_glyph_position = Some(vec2f(0., 0.)); // The autosuggestion location should be expressed in a soft-wrapped row coordinate // In other words, in what soft-wrapped row can we find the last character in the logical row that has the autosuggestion? let autosuggestion_soft_wrapped_line = view_snapshot .autosuggestion_location() .as_ref() .and_then(|l| match l { AutosuggestionLocation::EndOfBuffer => None, AutosuggestionLocation::Inline(logical_line_ix) => { Some(layout.frame_layouts.end_of_logical_row(*logical_line_ix)) } }); self.paint_lines( content_origin, layout, line_height, autosuggestion_soft_wrapped_line, ctx, app, &mut last_autosuggestion_glyph_position, ); let right_notch_origin = self.compute_right_notch_origin( layout, first_visible_row, element_origin, content_origin, last_autosuggestion_glyph_position, ); right_notch_origin.and_then(|right_notch_origin| { self.editor_decorator_elements .right_notch .as_mut() .map(|right_notch_element| { right_notch_element.paint(right_notch_origin, ctx, app); }) }); let line_origin = content_origin + vec2f( 0., layout.frame_layouts.num_displayed_lines() as f32 * line_height, ); // Draw the rest of the autosuggestion lines--we skip the first line as it is handled // by paint_lines since it's painted on the same line as the buffer. layout .placeholder_suggestion_text_line_layouts .iter() .skip(1) .enumerate() .for_each(|(ix, line)| { let line_origin = line_origin + vec2f(0., ix as f32 * line_height); line.paint_with_baseline_position( RectF::from_points( line_origin, self.bounds() .expect("layout() should have been called before paint()") .lower_right(), ), &Default::default(), self.text_colors.hint_color.into(), ctx.font_cache, ctx.scene, &view_snapshot.baseline_position_fn(), ); last_autosuggestion_glyph_position = Some(line_origin + vec2f(line.width, 0.)); }); let last_line = layout .frame_layouts .get_line(layout.frame_layouts.num_lines() - 1); let next_icon_origin_without_padding = last_autosuggestion_glyph_position .zip(last_line) .map(|(p, last_line)| { p + Self::cursor_origin_vertical_adjustment_from_text_content_origin( view_snapshot, ComputeBaselinePositionArgs { font_cache: ctx.font_cache, font_size: last_line.font_size, line_height_ratio: last_line.line_height_ratio, baseline_ratio: last_line.baseline_ratio, ascent: last_line.ascent, descent: last_line.descent, }, ) }); let mut current_icon_x_offset = 0.; if let Some((position, element)) = next_icon_origin_without_padding.zip(self.autosuggestion_shortcut_icon.as_mut()) { element.paint(position + vec2f(AUTOSUGGESTION_HINT_PADDING, 0.), ctx, app); current_icon_x_offset += AUTOSUGGESTION_HINT_PADDING + element.size().expect("should have size").x(); } // Paint the ignore button after the keybinding hint (if it exists) if let Some((position, ignore_element)) = next_icon_origin_without_padding.zip(self.autosuggestion_ignore_icon.as_mut()) { ignore_element.paint( position + vec2f(current_icon_x_offset + AUTOSUGGESTION_HINT_PADDING, 0.), ctx, app, ); } if let Some(cycle_next_command_hint) = self.cycle_next_command_hint.as_mut() { let hint_size = cycle_next_command_hint .size() .expect("should have element size at paint"); let origin = vec2f( bounds.max_x() - hint_size.x(), bounds.max_y() - hint_size.y(), ); // Only render the hint if it wouldn't overlap with the rightmost icon. if next_icon_origin_without_padding.is_none_or(|p| p.x() < origin.x()) { cycle_next_command_hint.paint(origin, ctx, app); } } let cursor_size = vec2f(BEAM_CURSOR_WIDTH_PX, cursor_height); for (id, point) in &view_snapshot.cached_buffer_points { // `cached_buffer_points` are expressed in buffer (DisplayPoint) coordinates, but // the editor's line layouts are indexed by soft-wrapped row. Convert before // looking up a line / computing pixel offsets. let display_point = DisplayPoint::new(point.row, point.column); let Some(soft_wrap_point) = layout .frame_layouts .to_soft_wrap_point(display_point, ClampDirection::Down) else { continue; }; let row = soft_wrap_point.row(); let Some(line) = &layout.frame_layouts.get_line(row as usize) else { continue; }; // `content_origin` is the origin of the first visible row. let relative_row = row as f32 - first_visible_row as f32; let y_offset = relative_row * line_height; // Match paint_lines: the left notch only affects the first visible line. let text_content_origin = if row == 0 && first_visible_row == 0 { content_origin + vec2f(layout.left_notch_layout_width_px, 0.) } else { content_origin }; let x_offset = line.x_for_index(soft_wrap_point.column() as usize); let bounds = text_content_origin + vec2f(x_offset, y_offset); ctx.position_cache.cache_position_indefinitely( position_id_for_cached_point(self.view_snapshot.view_id, id), RectF::new(bounds, cursor_size), ); } } if let Some(editor_repaint_at) = self.view_snapshot.get_editor_repaint_at() { ctx.repaint_at(editor_repaint_at); } } fn dispatch_event( &mut self, event: &DispatchedEvent, ctx: &mut EventContext, app: &AppContext, ) -> bool { let Some(z_index) = self.z_index() else { return false; }; // These icons have tooltips in overlay layers, so they need to be dispatched before checking event.at_z_index below. // This is because event.at_z_index will filter the event due to the overlay layer above. if let Some(icon) = &mut self.autosuggestion_shortcut_icon { if icon.bounds().is_some() && icon.dispatch_event(event, ctx, app) { return true; } } if let Some(ignore_icon) = &mut self.autosuggestion_ignore_icon { if ignore_icon.bounds().is_some() && ignore_icon.dispatch_event(event, ctx, app) { return true; } } // Since editor decorator elements may be clickable, we need to prioritize dispatching events from them. if let Some(top_section) = &mut self.editor_decorator_elements.top_section { if top_section.dispatch_event(event, ctx, app) { return true; } } if let Some(left_notch) = &mut self.editor_decorator_elements.left_notch { if left_notch.dispatch_event(event, ctx, app) { return true; } } if let Some(right_notch) = &mut self.editor_decorator_elements.right_notch { if right_notch.dispatch_event(event, ctx, app) { return true; } } let Some(event_at_z_index) = event.at_z_index(z_index, ctx) else { return false; }; let events_to_propagate_on = matches!( event_at_z_index, Event::MouseMoved { .. } | Event::LeftMouseDragged { .. } | Event::LeftMouseDown { .. } | Event::LeftMouseUp { .. } | Event::RightMouseDown { .. } ); // Intercept mouse events for floating elements, // similar to how it is done in dispatch_event() in block_list_element.rs if events_to_propagate_on { // Only pass through events to decorator elements which have been painted (this avoids cases such as the right prompt // not being painted, where the EventHandler will fail to unwrap a Z-index option)! if let Some(top_section) = self .editor_decorator_elements .top_section .as_mut() .filter(|n| n.z_index().is_some()) { if top_section.dispatch_event(event, ctx, app) { return true; } } if let Some(left_notch) = self .editor_decorator_elements .left_notch .as_mut() .filter(|n| n.z_index().is_some()) { if left_notch.dispatch_event(event, ctx, app) { return true; } } if let Some(right_notch) = self .editor_decorator_elements .right_notch .as_mut() .filter(|n| n.z_index().is_some()) { if right_notch.dispatch_event(event, ctx, app) { return true; } } } match event_at_z_index { Event::LeftMouseDown { position, modifiers, click_count, is_first_mouse, } => match *click_count { 1 => self.mouse_down(*position, modifiers, *is_first_mouse, ctx, app), 2 => self.double_mouse_down(*position, ctx), 3 => self.triple_mouse_down(*position, ctx), _ => false, }, Event::LeftMouseUp { position, .. } => self.mouse_up(*position, ctx, app), Event::LeftMouseDragged { position, .. } => self.mouse_dragged(*position, ctx, app), Event::ScrollWheel { position, delta, precise, modifiers: ModifiersState { ctrl: false, .. }, } => self.scroll(*position, *delta, *precise, ctx, app), Event::KeyDown { keystroke, .. } => self.key_down(keystroke, ctx), Event::ModifierKeyChanged { key_code, state, .. } => self.modifier_key_change(key_code, state, ctx), Event::TypedCharacters { chars } => self.typed_characters(chars, ctx), Event::DragAndDropFiles { paths, location } => { self.drag_and_drop_file(paths.clone(), *location, ctx) } Event::MouseMoved { position, .. } => self.mouse_moved(*position, ctx, app), Event::MiddleMouseDown { position, .. } => self.middle_mouse_down(*position, ctx), Event::SetMarkedText { marked_text, selected_range, } => self.set_marked_text(marked_text, selected_range, ctx), Event::ClearMarkedText => self.clear_marked_text(ctx), _ => false, } } fn size(&self) -> Option { self.layout.as_ref().map(|layout| layout.size) } fn origin(&self) -> Option { self.paint.as_ref().map(|paint| paint.origin) } } struct LayoutState { size: Vector2F, // Each frame is a row of text from the buffer. If the soft wrap option is on and a row // is too long to fit on the screen, it'll be split into multiple lines within // a frame. Otherwise, the frame is a singleton Line. frame_layouts: FrameLayouts, // Will hold either the suggestion text or placeholder text or empty vector, if neither exist. // Suggestion text should take precedence. placeholder_suggestion_text_line_layouts: Vec>, // This contains the shortcut icon that shows new users how to accept the autosuggestion. max_visible_line_width: f32, /// True if the `autoscroll_vertically` function on the editor view returns true /// and the soft wrap setting is off. autoscroll_horizontally: bool, /// Height of top section element, rendered above the EditorElement (defaults to 0 /// if no top element). top_section_height_px: f32, /// The width of the left notch and right notch elements when laid out (default to 0 if /// they doesn't exist). left_notch_layout_width_px: f32, right_notch_layout_width_px: f32, } impl LayoutState { /// Computes the scroll width of this editor in pixels. fn scroll_width( &self, view_snapshot: &ViewSnapshot, text_layout_cache: &LayoutCache, app: &AppContext, ) -> f32 { if self.autoscroll_horizontally { let row = view_snapshot.rightmost_point(app).row(); let longest_line_width = view_snapshot .layout_line(row, text_layout_cache, app) .expect("Should have layout line") .width; longest_line_width.max(self.max_visible_line_width) + view_snapshot.em_width } else { let last_line_width = self .frame_layouts .displayed_lines() .last() .map_or(0., |line| line.width); let placeholder_or_suggestion_width = self .placeholder_suggestion_text_line_layouts .first() .map_or(0., |line| line.width); last_line_width + placeholder_or_suggestion_width + view_snapshot.em_width } } /// The maximum bottom-right scrolling position in pixels. fn scroll_max( &self, view_snapshot: &ViewSnapshot, text_layout_cache: &LayoutCache, app: &AppContext, ) -> Vector2F { let horizontal_scrolling_max = ((self.scroll_width(view_snapshot, text_layout_cache, app) - self.size.x()) / view_snapshot.em_width) .max(0.0); // Total number of lines, including the top section. let total_lines = self .frame_layouts .frames() .fold(0, |acc, frame| acc + frame.lines().len()) as f32 + self.top_section_height_px / view_snapshot.line_height; let max_scroll_top = ViewSnapshot::max_scroll_top(total_lines, self.size.y() / view_snapshot.line_height); vec2f(horizontal_scrolling_max, max_scroll_top) } } struct PaintState { rect: RectF, origin: Point, } /// The display point might be clamped at either end of a line. pub struct PossibleDisplayPoint { pub display_point: DisplayPoint, /// Whether the display point is clamped to either the beginning or end of the line. pub is_clamped: bool, pub clamp_direction: ClampDirection, } impl PaintState { /// Returns the display point for the given position, /// clamping the row/col if necessary. fn point_for_position( &self, view_snapshot: &ViewSnapshot, scroll_position: Vector2F, layout: &LayoutState, position: Vector2F, ) -> DisplayPoint { self.possible_point_for_position(view_snapshot, scroll_position, layout, position) .display_point } /// Returns a possible display point for the given position, /// which includes whether or not the display_point has been clamped. /// /// This method supports soft-wrapping. fn possible_point_for_position( &self, view_snapshot: &ViewSnapshot, scroll_position: Vector2F, layout: &LayoutState, position: Vector2F, ) -> PossibleDisplayPoint { let mut is_clamped = false; let position = position - self.rect.origin(); let y = position.y().max(0.0).min(layout.size.y()); let num_rows = layout.frame_layouts.num_lines(); let max_row = num_rows as u32 - 1; // Offset by the top section, which shouldn't be considered for purposes of position -> display point. let shifted_scroll_position_y = scroll_position.y() - layout.top_section_height_px / view_snapshot.line_height; let mut row: u32 = ((y / view_snapshot.line_height) + shifted_scroll_position_y) as u32; if row > max_row { is_clamped = true; row = max_row; } let shifted_position = if row == 0 { // Note that the given position is shifted for the left notch's width, but we don't // want the DisplayPoint col to be shifted over, hence we subtract the width. vec2f( position.x() - layout.left_notch_layout_width_px, position.y(), ) } else { position }; let line = layout .frame_layouts .get_line(row as usize) .expect("Should be clamped to last possible line"); let x = shifted_position.x() + (scroll_position.x() * view_snapshot.em_width); let col = if let Some(col) = line.index_for_x(x).map(|ix| ix as u32) { col } else { // Clamp to the left or right if the x pos is before or after the buffer text, respectively. is_clamped = true; if x >= 0. { // If the line has no glyphs, the index is zero. Otherwise, we take one more index // beyond the last start index in the line to get the last position. line.last_glyph() .map_or(0, |glyph| (glyph.index + 1) as u32) } else { 0 } }; // Now convert from SoftWrapPoint to DisplayPoint. let soft_wrap_point = SoftWrapPoint::new(row, col); let display_point_and_clamp_direction = layout.frame_layouts.to_display_point(soft_wrap_point); PossibleDisplayPoint { display_point: display_point_and_clamp_direction.point, is_clamped, clamp_direction: display_point_and_clamp_direction.clamp_direction, } } #[allow(clippy::too_many_arguments)] fn scroll_position( &self, view_snapshot: &ViewSnapshot, scroll_state: &ScrollState, layout: &LayoutState, position: Vector2F, ctx: &EventContext, app: &AppContext, ) -> Vector2F { let rect = self.rect; let mut scroll_delta = Vector2F::zero(); let vertical_margin = view_snapshot.line_height.min(rect.height() / 3.0); let top = rect.origin_y() + vertical_margin; let bottom = rect.lower_left().y() - vertical_margin; if position.y() < top { scroll_delta.set_y(-scale_vertical_mouse_autoscroll_delta(top - position.y())) } if position.y() > bottom { scroll_delta.set_y(scale_vertical_mouse_autoscroll_delta(position.y() - bottom)) } let horizontal_margin = view_snapshot.line_height.min(rect.width() / 3.0); let left = rect.origin_x() + horizontal_margin; let right = rect.upper_right().x() - horizontal_margin; if position.x() < left { scroll_delta.set_x(-scale_horizontal_mouse_autoscroll_delta( left - position.x(), )) } if position.x() > right { scroll_delta.set_x(scale_horizontal_mouse_autoscroll_delta( position.x() - right, )) } (scroll_state.scroll_position() + scroll_delta).clamp( Vector2F::zero(), layout.scroll_max(view_snapshot, ctx.text_layout_cache, app), ) } } fn scale_vertical_mouse_autoscroll_delta(delta: f32) -> f32 { delta.powf(1.5) / 100.0 } fn scale_horizontal_mouse_autoscroll_delta(delta: f32) -> f32 { delta.powf(1.2) / 300.0 } #[cfg(test)] #[path = "element_tests.rs"] mod tests;