Files
galaxy/app/src/editor/view/element.rs
T

2433 lines
98 KiB
Rust

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<Mutex<Option<CommandXRayMouseState>>>;
// 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<Box<dyn Element>>,
/// 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<Box<dyn Element>>,
// 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<Box<dyn Element>>,
// 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<Vector2F>,
}
/// 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<dyn Element>,
}
impl From<super::RemoteDrawableSelectionData> 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<LayoutState>,
paint: Option<PaintState>,
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<Box<dyn Element>>,
autosuggestion_ignore_icon: Option<Box<dyn Element>>,
cycle_next_command_hint: Option<Box<dyn Element>>,
vim_mode: Option<VimMode>,
text_colors: TextColors,
editor_decorator_elements: EditorDecoratorElements,
local_selection_data: LocalDrawableSelectionData,
remote_selections_data: HashMap<ReplicaId, RemoteDrawableSelectionData>,
voice_input_cursor_icon: Option<Box<dyn Element>>,
#[cfg_attr(not(feature = "voice_input"), allow(unused))]
voice_input_toggle_key_code: Option<KeyCode>,
}
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<VimMode>,
text_colors: TextColors,
editor_decorator_elements: EditorDecoratorElements,
local_selection_data: LocalDrawableSelectionData,
remote_selections_data: HashMap<ReplicaId, super::RemoteDrawableSelectionData>,
cursor_display_type: Option<CursorDisplayType>,
voice_input_toggle_key_code: Option<KeyCode>,
) -> 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<CommandXRayMouseState>,
new_position: Option<Vector2F>,
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<usize>,
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<String>,
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<SoftWrapPoint>,
visible_start: SoftWrapPoint,
visible_end: SoftWrapPoint,
) -> Range<u32> {
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<SoftWrapPoint>,
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<ReplicaId, RemoteDrawableSelectionData>,
voice_input_icon: &mut Option<Box<dyn Element>>,
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<usize>,
ctx: &mut PaintContext,
app: &AppContext,
last_autosuggestion_glyph_position: &mut Option<Vector2F>,
) {
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<Vector2F>,
) -> Option<Vector2F> {
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<FrameLayouts> {
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<AcceptAutosuggestionKeybinding>,
autosuggestion_ignore: &ViewHandle<AutosuggestionIgnore>,
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<dyn Element + 'static> {
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<dyn Element>) -> 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<dyn Element>| {
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<Vector2F> {
self.layout.as_ref().map(|layout| layout.size)
}
fn origin(&self) -> Option<Point> {
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<Arc<text_layout::Line>>,
// 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;