Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# Input Box
|
||||
|
||||
## 1. APIs of the Input Box
|
||||
|
||||
The input box is a child view named `EditorView` instantiated within `TerminalView`.
|
||||
|
||||
For the editor view to communicate with the terminal view, have the editor view send an action and register the action in the parent view.
|
||||
|
||||
For the terminal view to communicate with the editor view, the terminal view can directly access the editor view via a field in its struct. For it to input a newline, for example, it calls:
|
||||
|
||||
```
|
||||
fn input_newline(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
|
||||
...
|
||||
self.input.update(ctx, |input, ctx| input.insert(&'\n'.into(), ctx));
|
||||
...
|
||||
```
|
||||
|
||||
## 2. Editor
|
||||
|
||||
### Buffer
|
||||
|
||||
The data structure that contains the text in the EditorView is the `Buffer`.
|
||||
|
||||
Its most commonly used API of the `Buffer` is `chars_at()`, which returns an iterator we can use to traverse characters from a certain point.
|
||||
|
||||
Under the hood, the buffer is a `SumTree<Fragment>`. Each `Fragment` has a `Text`. The `Text` consists of `text` which is an `Arc<str>` and `runs` which is a `SumTree<Run>`. A run describes how much space the fragment is taking.
|
||||
|
||||
Another useful API of `Buffer` is `line_len(row_number)` which gives you the length of a row number.
|
||||
|
||||
### Indexing into the Buffer
|
||||
|
||||
We use `Point` and `Offset` to index into the buffer. `Point` is a 2-dimensional location with `row` and `column` within the `Buffer`. `Offset` is a 1-dimensional `usize` within the `Buffer`. You can easily convert `Point` and `Anchor` to `Offset` via `to_offset()`. `Offset` is useful for traversing through the characters without having to worry about changing row and column numbers.
|
||||
|
||||
### DisplayPoints and DisplayMap
|
||||
|
||||
Another struct with `row` and `column` is the `DisplayPoint`. `DisplayPoint` is only relevant in `EditorView` and `DisplayMap`, but not the `Buffer`.
|
||||
`DisplayPoint` describes the location in the `DisplayMap`. The `DisplayMap` describes how the points **appear**—the visual coordinate system. The `Buffer` has no concept of how it's being displayed and could in fact be displayed in multiple views.
|
||||
|
||||
The values of `DisplayPoint`s and `Point`s differ when code folding or softwrapping occurs. We can translate between the `DisplayPoint` and `Point` using the `DisplayMap`.
|
||||
|
||||
### Selections and Cursors
|
||||
|
||||
The input box supports the multiple selections we are used to in VSCode. So our `EditorView` has a `Vec<Selection>`.
|
||||
|
||||
A `Selection` has a `start` anchor and an `end` anchor to denote its start and end position.
|
||||
|
||||
Selections and cursors are closely intertwined—whereever there is a selection, there is a cursor. **A cursor on its own is just an empty selection where `start==end`.** As such, there is always at least one `Selection`, with the first selection being the cursor.
|
||||
|
||||
### Anchors
|
||||
|
||||
An `Anchor` is a bookmark into the text. It allows us to index for a relative position in the text even if its absolute position has changed. An `Anchor` can be converted to a `DisplayPoint` or a `Point`.
|
||||
|
||||
For example, imagine my cursor is between the characters 'l' and 'E' in 'PartialEq'. Say the absolute position is row 3 column 7:
|
||||
|
||||
```
|
||||
Partial|Eq
|
||||
```
|
||||
|
||||
Then, we add 10 characters:
|
||||
|
||||
```
|
||||
Partial1234567890|Eq
|
||||
```
|
||||
|
||||
The cursor's absolute position is now at row 3 column 17. To easily calculate this position, we can convert the `Anchor` to row 3 column 17.
|
||||
|
||||
#### `AnchorBias::Left`, `AnchorBias::Right`
|
||||
|
||||
The AnchorBias determines whether the cursor ends up on the right side or the left side of the inserted text. The above example is when AnchorBias is `Right`. `AnchorBias::Left` looks like this:
|
||||
|
||||
```
|
||||
Partial|1234567890Eq
|
||||
```
|
||||
|
||||
`Anchor`s are most useful when we introduce collaborative editing, and it is important for users to know where they are typing in even when another user inserts text in the line they are on.
|
||||
@@ -0,0 +1,369 @@
|
||||
//! This module contains the code for the editable accept autosuggestion keybinding
|
||||
//! shown inline in the input.
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME;
|
||||
use crate::menu::{Menu, MenuItemFields};
|
||||
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
||||
use crate::terminal::input::OPEN_COMPLETIONS_KEYBINDING_NAME;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::bindings::{
|
||||
keybinding_name_to_keystroke, reset_keybinding_to_default, set_custom_keybinding,
|
||||
};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use lazy_static::lazy_static;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{Border, ChildView, Flex, ParentElement};
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Radius, DEFAULT_UI_LINE_HEIGHT_RATIO,
|
||||
};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::ui_components::keyboard_shortcut::KeyboardShortcut;
|
||||
use warpui::ViewContext;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ChildAnchor, CornerRadius, Element, Hoverable, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentOffsetBounds, Stack,
|
||||
},
|
||||
AppContext, SingletonEntity,
|
||||
};
|
||||
use warpui::{Entity, TypedActionView, View, ViewHandle};
|
||||
|
||||
use super::EditorElement;
|
||||
|
||||
pub const AUTOSUGGESTION_HINT_MINIMUM_HEIGHT: f32 = 12.;
|
||||
|
||||
lazy_static! {
|
||||
static ref TAB_KEYSTROKE: Keystroke = Keystroke {
|
||||
key: "tab".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
static ref CTRL_SPACE_KEYSTROKE: Keystroke = Keystroke {
|
||||
key: " ".into(),
|
||||
ctrl: true,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
pub struct AcceptAutosuggestionKeybinding {
|
||||
is_menu_open: bool,
|
||||
select_keybinding_menu: ViewHandle<Menu<AcceptAutosuggestionKeybindingAction>>,
|
||||
|
||||
autosuggestion_hint_mouse_handle: MouseStateHandle,
|
||||
accept_autosuggestion_keybinding: Option<Keystroke>,
|
||||
}
|
||||
|
||||
pub enum AcceptAutosuggestionKeybindingEvent {
|
||||
SetAcceptAutosuggestionKeybinding {},
|
||||
OpenSettingsForCustomKeybinding,
|
||||
}
|
||||
|
||||
impl Entity for AcceptAutosuggestionKeybinding {
|
||||
type Event = AcceptAutosuggestionKeybindingEvent;
|
||||
}
|
||||
|
||||
impl AcceptAutosuggestionKeybinding {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let accept_autosuggestion_keybinding =
|
||||
keybinding_name_to_keystroke(ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME, ctx);
|
||||
let select_keybinding_menu = ctx.add_typed_action_view(|ctx| {
|
||||
Menu::new().with_border(
|
||||
Border::all(1.)
|
||||
.with_border_color(blended_colors::neutral_4(Appearance::as_ref(ctx).theme())),
|
||||
)
|
||||
});
|
||||
let right_arrow = Keystroke::parse("right").expect("can parse keystroke");
|
||||
let tab = Keystroke::parse("tab").expect("can parse keystroke");
|
||||
let shift_right_arrow = Keystroke::parse("shift-right").expect("can parse keystroke");
|
||||
let menu_items: Vec<crate::menu::MenuItem<AcceptAutosuggestionKeybindingAction>> = vec![
|
||||
MenuItemFields::new(right_arrow.displayed())
|
||||
.with_on_select_action(
|
||||
AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding {
|
||||
keystroke: right_arrow,
|
||||
},
|
||||
)
|
||||
.into_item(),
|
||||
MenuItemFields::new(tab.displayed().to_lowercase())
|
||||
.with_on_select_action(
|
||||
AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding {
|
||||
keystroke: tab,
|
||||
},
|
||||
)
|
||||
.into_item(),
|
||||
MenuItemFields::new(shift_right_arrow.displayed().to_lowercase())
|
||||
.with_on_select_action(
|
||||
AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding {
|
||||
keystroke: shift_right_arrow,
|
||||
},
|
||||
)
|
||||
.into_item(),
|
||||
MenuItemFields::new("Custom...")
|
||||
.with_on_select_action(
|
||||
AcceptAutosuggestionKeybindingAction::OpenSettingsForCustomKeybinding,
|
||||
)
|
||||
.into_item(),
|
||||
];
|
||||
select_keybinding_menu.update(ctx, |menu, ctx| {
|
||||
menu.set_items(menu_items, ctx);
|
||||
});
|
||||
ctx.subscribe_to_view(&select_keybinding_menu, |me, _, event, ctx| {
|
||||
if let crate::menu::Event::Close { .. } = event {
|
||||
me.close_menu(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to when the accept autosuggestion keybinding changes.
|
||||
let notifier = KeybindingChangedNotifier::handle(ctx);
|
||||
ctx.subscribe_to_model(¬ifier, |me, _, event, ctx| match event {
|
||||
KeybindingChangedEvent::BindingChanged {
|
||||
binding_name,
|
||||
new_trigger,
|
||||
} => {
|
||||
if binding_name == ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME {
|
||||
me.accept_autosuggestion_keybinding = new_trigger.clone();
|
||||
me.update_menu_selected_item(ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut me = Self {
|
||||
is_menu_open: false,
|
||||
select_keybinding_menu,
|
||||
autosuggestion_hint_mouse_handle: Default::default(),
|
||||
accept_autosuggestion_keybinding,
|
||||
};
|
||||
me.update_menu_selected_item(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
pub fn close_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_menu_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Updates the menu selected item based on the current accept autosuggestion keybinding.
|
||||
fn update_menu_selected_item(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let accept_autosuggestion_keybinding_displayed = self
|
||||
.accept_autosuggestion_keybinding
|
||||
.as_ref()
|
||||
.map(|keystroke| keystroke.displayed().to_lowercase());
|
||||
self.select_keybinding_menu.update(ctx, |menu, ctx| {
|
||||
if let Some(accept_autosuggestion_keybinding_displayed) =
|
||||
accept_autosuggestion_keybinding_displayed
|
||||
{
|
||||
let found =
|
||||
menu.set_selected_by_name(accept_autosuggestion_keybinding_displayed, ctx);
|
||||
// If the keybinding is not one of our default options, select the "Custom..." item.
|
||||
if !found {
|
||||
menu.set_selected_by_name("Custom...", ctx);
|
||||
}
|
||||
} else {
|
||||
// If the keybinding is not set, we show right arrow which always works.
|
||||
menu.set_selected_by_action(
|
||||
&AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding {
|
||||
keystroke: Keystroke::parse("right").expect("can parse keystroke"),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AcceptAutosuggestionKeybindingAction {
|
||||
SetAcceptAutosuggestionKeybinding { keystroke: Keystroke },
|
||||
OpenSettingsForCustomKeybinding,
|
||||
OpenMenu,
|
||||
}
|
||||
|
||||
impl TypedActionView for AcceptAutosuggestionKeybinding {
|
||||
type Action = AcceptAutosuggestionKeybindingAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AcceptAutosuggestionKeybindingAction::SetAcceptAutosuggestionKeybinding {
|
||||
keystroke,
|
||||
} => {
|
||||
// When set to right arrow, we reset to the default (clears keybinding)
|
||||
// so that it's still possible to use right arrow to navigate the input when the cursor isn't at the end of the line.
|
||||
if keystroke.displayed() == "→" {
|
||||
reset_keybinding_to_default(ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME, ctx);
|
||||
} else {
|
||||
// Whatever keybinding is set here will always work regardless of where the cursor is.
|
||||
set_custom_keybinding(ACCEPT_AUTOSUGGESTION_KEYBINDING_NAME, keystroke, ctx);
|
||||
}
|
||||
|
||||
let open_completions_keystroke =
|
||||
keybinding_name_to_keystroke(OPEN_COMPLETIONS_KEYBINDING_NAME, ctx);
|
||||
// If we're setting accept autosuggestion to tab and that conflicts with open completions,
|
||||
// set open completions to ctrl-space.
|
||||
if *keystroke == *TAB_KEYSTROKE
|
||||
&& open_completions_keystroke
|
||||
.as_ref()
|
||||
.is_some_and(|keystroke| *keystroke == *TAB_KEYSTROKE)
|
||||
{
|
||||
set_custom_keybinding(
|
||||
OPEN_COMPLETIONS_KEYBINDING_NAME,
|
||||
&CTRL_SPACE_KEYSTROKE,
|
||||
ctx,
|
||||
);
|
||||
} else if *keystroke != *TAB_KEYSTROKE
|
||||
&& open_completions_keystroke
|
||||
.is_some_and(|keystroke| keystroke == *CTRL_SPACE_KEYSTROKE)
|
||||
{
|
||||
// If we're setting accept autosuggestion to anything other than tab, switch open completions back to tab if it was set to ctrl-space above.
|
||||
// Note that we can't tell if the user explicitly set it to ctrl-space vs we did it as a default above.
|
||||
reset_keybinding_to_default(OPEN_COMPLETIONS_KEYBINDING_NAME, ctx);
|
||||
}
|
||||
}
|
||||
AcceptAutosuggestionKeybindingAction::OpenSettingsForCustomKeybinding => ctx
|
||||
.dispatch_typed_action(&WorkspaceAction::ConfigureKeybindingSettings {
|
||||
keybinding_name: Some("Accept Autosuggestion".to_owned()),
|
||||
}),
|
||||
AcceptAutosuggestionKeybindingAction::OpenMenu => {
|
||||
self.is_menu_open = true;
|
||||
}
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl View for AcceptAutosuggestionKeybinding {
|
||||
fn ui_name() -> &'static str {
|
||||
"AcceptAutosuggestionKeybinding"
|
||||
}
|
||||
|
||||
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
// Because the icon's origin is NOT the top of the line but the top of the cursor,
|
||||
// we should render it with line height ratio no larger than DEFAULT_UI_LINE_HEIGHT_RATIO.
|
||||
// With larger line height ratios, there wouldn't be enough space in the icon to render the text.
|
||||
// But we do need to account for smaller line height ratios so the text can be rendered in the smaller space.
|
||||
let line_height_ratio = appearance
|
||||
.line_height_ratio()
|
||||
.min(DEFAULT_UI_LINE_HEIGHT_RATIO);
|
||||
// We want the keybinding icon to be the same height as the cursor in the input.
|
||||
let height =
|
||||
EditorElement::cursor_height(appearance.monospace_font_size(), line_height_ratio)
|
||||
.max(AUTOSUGGESTION_HINT_MINIMUM_HEIGHT);
|
||||
let disabled_color = blended_colors::semantic_text_disabled(appearance.theme());
|
||||
// If there's no keybinding set, right arrow always works.
|
||||
let keystroke = self
|
||||
.accept_autosuggestion_keybinding
|
||||
.clone()
|
||||
.unwrap_or(Keystroke::parse("right").expect("can parse keystroke"));
|
||||
let border_width = 1.;
|
||||
|
||||
// The stack contains the editable keybinding, tooltip that shows upon mouse hover,
|
||||
// and menu on click.
|
||||
let mut stack = Stack::new();
|
||||
if self.is_menu_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&self.select_keybinding_menu).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -1.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let is_menu_open = self.is_menu_open;
|
||||
Hoverable::new(self.autosuggestion_hint_mouse_handle.clone(), |state| {
|
||||
// Colors are inverted when hovered or menu is open.
|
||||
let (font_color, background_color) = if is_menu_open || state.is_hovered() {
|
||||
(
|
||||
appearance.theme().background().into(),
|
||||
Some(blended_colors::semantic_text_disabled(appearance.theme())),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
blended_colors::semantic_text_disabled(appearance.theme()),
|
||||
None,
|
||||
)
|
||||
};
|
||||
let font_size = appearance.monospace_font_size() - 2.;
|
||||
// We use UI font family for the keyboard shortcut even though it's in the input
|
||||
// because monospace font family is configurable, and might not handle symbols like
|
||||
// modifiers and arrows well.
|
||||
let keystroke = KeyboardShortcut::new(
|
||||
&keystroke,
|
||||
UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(font_size),
|
||||
font_color: Some(font_color),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.lowercase_modifier()
|
||||
.text_only()
|
||||
.with_line_height_ratio(line_height_ratio)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let height_without_border = height - border_width * 2.;
|
||||
let chevron_down = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::ArrowDropDown
|
||||
.to_warpui_icon(Fill::Solid(font_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(height_without_border)
|
||||
.with_width(height_without_border)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut editable_keystroke = Container::new(
|
||||
Flex::row()
|
||||
.with_children([keystroke, chevron_down])
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
// Padding on the left for the keyboard shortcut.
|
||||
// The arrow down icon already has its own padding so no need on the right.
|
||||
.with_padding_left(4.)
|
||||
.with_border(Border::all(border_width).with_border_color(disabled_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(25.)));
|
||||
|
||||
if let Some(background_color) = background_color {
|
||||
editable_keystroke = editable_keystroke.with_background_color(background_color);
|
||||
}
|
||||
|
||||
let editable_keystroke_element = ConstrainedBox::new(editable_keystroke.finish())
|
||||
.with_max_height(height)
|
||||
.finish();
|
||||
|
||||
stack.add_child(editable_keystroke_element);
|
||||
|
||||
// Add tooltip on hover.
|
||||
if !is_menu_open && state.is_hovered() {
|
||||
let tool_tip = appearance
|
||||
.ui_builder()
|
||||
.autosuggestion_tool_tip("Change keybinding".into())
|
||||
.build()
|
||||
.finish();
|
||||
stack.add_positioned_overlay_child(
|
||||
tool_tip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -4.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AcceptAutosuggestionKeybindingAction::OpenMenu);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! This module contains the code for the ignore button shown inline next to autosuggestions.
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
ChildAnchor, ConstrainedBox, Container, CornerRadius, Element, Hoverable, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{Entity, TypedActionView, View};
|
||||
use warpui::{SingletonEntity, ViewContext};
|
||||
|
||||
use super::EditorElement;
|
||||
|
||||
pub const AUTOSUGGESTION_IGNORE_MINIMUM_HEIGHT: f32 = 12.;
|
||||
|
||||
pub struct AutosuggestionIgnore {
|
||||
autosuggestion_ignore_mouse_handle: MouseStateHandle,
|
||||
current_autosuggestion: Option<String>,
|
||||
}
|
||||
|
||||
pub enum AutosuggestionIgnoreEvent {
|
||||
IgnoreAutosuggestion { suggestion: String },
|
||||
}
|
||||
|
||||
impl Default for AutosuggestionIgnore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AutosuggestionIgnore {
|
||||
type Event = AutosuggestionIgnoreEvent;
|
||||
}
|
||||
|
||||
impl AutosuggestionIgnore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
autosuggestion_ignore_mouse_handle: Default::default(),
|
||||
current_autosuggestion: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_current_autosuggestion(&mut self, suggestion: Option<String>) {
|
||||
self.current_autosuggestion = suggestion;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AutosuggestionIgnoreAction {
|
||||
IgnoreCurrentAutosuggestion,
|
||||
}
|
||||
|
||||
impl TypedActionView for AutosuggestionIgnore {
|
||||
type Action = AutosuggestionIgnoreAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AutosuggestionIgnoreAction::IgnoreCurrentAutosuggestion => {
|
||||
if let Some(suggestion) = &self.current_autosuggestion {
|
||||
ctx.emit(AutosuggestionIgnoreEvent::IgnoreAutosuggestion {
|
||||
suggestion: suggestion.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl View for AutosuggestionIgnore {
|
||||
fn ui_name() -> &'static str {
|
||||
"AutosuggestionIgnore"
|
||||
}
|
||||
|
||||
fn render(&self, ctx: &warpui::AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
// Because the icon's origin is NOT the top of the line but the top of the cursor,
|
||||
// we should render it with line height ratio no larger than DEFAULT_UI_LINE_HEIGHT_RATIO.
|
||||
// With larger line height ratios, there wouldn't be enough space in the icon to render the text.
|
||||
// But we do need to account for smaller line height ratios so the text can be rendered in the smaller space.
|
||||
let line_height_ratio = appearance
|
||||
.line_height_ratio()
|
||||
.min(warpui::elements::DEFAULT_UI_LINE_HEIGHT_RATIO);
|
||||
// We want the ignore icon to be the same height as the cursor in the input.
|
||||
let height =
|
||||
EditorElement::cursor_height(appearance.monospace_font_size(), line_height_ratio)
|
||||
.max(AUTOSUGGESTION_IGNORE_MINIMUM_HEIGHT);
|
||||
let disabled_color = blended_colors::semantic_text_disabled(appearance.theme());
|
||||
let border_width = 1.;
|
||||
|
||||
// The stack contains the ignore button and tooltip that shows upon mouse hover.
|
||||
let mut stack = Stack::new();
|
||||
|
||||
Hoverable::new(self.autosuggestion_ignore_mouse_handle.clone(), |state| {
|
||||
// Colors are inverted when hovered.
|
||||
let (icon_color, background_color) = if state.is_hovered() {
|
||||
(
|
||||
appearance.theme().background().into(),
|
||||
Some(blended_colors::semantic_text_disabled(appearance.theme())),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
blended_colors::semantic_text_disabled(appearance.theme()),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let height_without_border = height - border_width * 2.;
|
||||
let close_icon = Container::new(
|
||||
ConstrainedBox::new(Icon::X.to_warpui_icon(Fill::Solid(icon_color)).finish())
|
||||
.with_height(height_without_border)
|
||||
.with_width(height_without_border)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut ignore_button = Container::new(close_icon)
|
||||
.with_uniform_padding(2.)
|
||||
.with_border(
|
||||
warpui::elements::Border::all(border_width).with_border_color(disabled_color),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(25.)));
|
||||
|
||||
if let Some(background_color) = background_color {
|
||||
ignore_button = ignore_button.with_background_color(background_color);
|
||||
}
|
||||
|
||||
let ignore_button_element = ConstrainedBox::new(ignore_button.finish())
|
||||
.with_max_height(height)
|
||||
.finish();
|
||||
|
||||
stack.add_child(ignore_button_element);
|
||||
|
||||
if state.is_hovered() {
|
||||
let tool_tip = appearance
|
||||
.ui_builder()
|
||||
.autosuggestion_tool_tip("Ignore this suggestion".into())
|
||||
.build()
|
||||
.finish();
|
||||
stack.add_positioned_overlay_child(
|
||||
tool_tip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -5.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AutosuggestionIgnoreAction::IgnoreCurrentAutosuggestion);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
pub mod accept_autosuggestion_keybinding_view;
|
||||
pub mod autosuggestion_ignore_view;
|
||||
mod soft_wrap;
|
||||
mod view;
|
||||
|
||||
/// Consumers of the editor should only interface with the view.
|
||||
/// They should _not_ be able to interface with the internal
|
||||
/// details of the editor (e.g. the [`Buffer`]).
|
||||
pub use view::*;
|
||||
pub use warpui::text::point::Point;
|
||||
|
||||
use std::{cmp, ops::Range};
|
||||
use warpui::AppContext;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
view::init(app);
|
||||
}
|
||||
|
||||
trait RangeExt<T> {
|
||||
fn sorted(&self) -> (T, T);
|
||||
}
|
||||
|
||||
impl<T: Ord + Clone> RangeExt<T> for Range<T> {
|
||||
fn sorted(&self) -> (T, T) {
|
||||
(
|
||||
cmp::min(&self.start, &self.end).clone(),
|
||||
cmp::max(&self.start, &self.end).clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
pub mod tests;
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Helper utilities for testing the editor.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
pub fn sample_text(rows: usize, cols: usize) -> String {
|
||||
let mut text = String::new();
|
||||
for row in 0..rows {
|
||||
let c: char = ('a' as u32 + row as u32) as u8 as char;
|
||||
let mut line = c.to_string().repeat(cols);
|
||||
if row < rows - 1 {
|
||||
line.push('\n');
|
||||
}
|
||||
text += &line;
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub struct RandomCharIter<T: Rng>(T);
|
||||
|
||||
impl<T: Rng> RandomCharIter<T> {
|
||||
pub fn new(rng: T) -> Self {
|
||||
Self(rng)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Rng> Iterator for RandomCharIter<T> {
|
||||
type Item = char;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.0.gen_bool(1.0 / 5.0) {
|
||||
Some('\n')
|
||||
} else {
|
||||
Some(self.0.gen_range(b'a'..b'z' + 1).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use anyhow::anyhow;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
use warpui::text_layout;
|
||||
|
||||
use crate::editor::{view::DisplayPoint, Point};
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug)]
|
||||
pub struct SoftWrapPoint(Point);
|
||||
|
||||
impl SoftWrapPoint {
|
||||
pub fn new(row: u32, col: u32) -> Self {
|
||||
Self(Point::new(row, col))
|
||||
}
|
||||
|
||||
pub fn row(self) -> u32 {
|
||||
self.0.row
|
||||
}
|
||||
|
||||
pub fn column(self) -> u32 {
|
||||
self.0.column
|
||||
}
|
||||
|
||||
pub fn column_mut(&mut self) -> &mut u32 {
|
||||
&mut self.0.column
|
||||
}
|
||||
}
|
||||
|
||||
/// When a line is soft-wrapped, there can be ambuigity about where the cursor
|
||||
/// should be drawn. For example, if I had the text "hello world" which became
|
||||
/// soft wrapped to "hello \nworld", then if the cursor is just before "w",
|
||||
/// then the cursor could either be at the end of the first line or the very
|
||||
/// beginning of the next line. This field keeps track of whether the cursor
|
||||
/// should be clamped above or below.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
|
||||
pub enum ClampDirection {
|
||||
Up,
|
||||
#[default]
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
/// Wrapper around `FrameLayouts` to help manage the levels of indirection around
|
||||
/// the Arc, Mutex and Option.
|
||||
///
|
||||
/// When this is None, we haven't yet laid out the text.
|
||||
pub struct SoftWrapState(Arc<Mutex<Option<FrameLayouts>>>);
|
||||
|
||||
impl SoftWrapState {
|
||||
pub fn update(&self, new_value: FrameLayouts) {
|
||||
*self.0.lock() = Some(new_value);
|
||||
}
|
||||
|
||||
pub fn read<T, F: FnOnce(anyhow::Result<&FrameLayouts>) -> T>(&self, callback: F) -> T {
|
||||
let frame_layouts = self.0.lock();
|
||||
match &*frame_layouts {
|
||||
Some(frame_layouts) => callback(Ok(frame_layouts)),
|
||||
None => callback(Err(anyhow!("No frame layout. This should only happen if we attempt to read before a frame of the editor has been laid out"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// We layout more frames than we need to display on the screen because
|
||||
/// we need to know the number of lines in previous frames to calculate
|
||||
/// any row position for any given character.
|
||||
///
|
||||
/// In particular, we always layout from zero up to the number of lines in
|
||||
/// viewport + number of lines in scrollback because this is the worst case
|
||||
/// and we don't know how many rows we'll need to be laid out until we've laid
|
||||
/// them out.
|
||||
#[derive(Clone)]
|
||||
pub struct FrameLayouts {
|
||||
frames: Vec<Arc<text_layout::TextFrame>>,
|
||||
/// The first line that is displayed. The very first line in the editor
|
||||
/// is 0, and each displayed line is incremented by 1. So if soft wrapping
|
||||
/// is off, this is one-to-one with the rows of the buffer, but if soft
|
||||
/// wrapping is on, the line number is the number of lines from the top
|
||||
/// given soft wrapping.
|
||||
start_line: u32,
|
||||
end_line: u32,
|
||||
}
|
||||
|
||||
impl FrameLayouts {
|
||||
pub fn new(frames: Vec<Arc<text_layout::TextFrame>>, start_line: u32, end_line: u32) -> Self {
|
||||
Self {
|
||||
frames,
|
||||
start_line,
|
||||
end_line,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frames(&self) -> impl Iterator<Item = &Arc<text_layout::TextFrame>> {
|
||||
self.frames.iter()
|
||||
}
|
||||
|
||||
pub fn last_frame(&self) -> Option<&Arc<text_layout::TextFrame>> {
|
||||
self.frames.last()
|
||||
}
|
||||
|
||||
pub fn displayed_lines(&self) -> impl Iterator<Item = &text_layout::Line> {
|
||||
FrameLayoutDisplayedLines::zero(self).skip(self.start_line as usize)
|
||||
}
|
||||
|
||||
/// Number of lines displayed in the editor.
|
||||
pub fn num_displayed_lines(&self) -> u32 {
|
||||
self.displayed_lines().count() as u32
|
||||
}
|
||||
|
||||
pub fn num_lines(&self) -> usize {
|
||||
self.frames().map(|frame| frame.lines().len()).sum()
|
||||
}
|
||||
|
||||
/// Returns the index of the row that represents the end of some logical row
|
||||
pub fn end_of_logical_row(&self, logical_row: usize) -> usize {
|
||||
self.frames
|
||||
.iter()
|
||||
// adding 1 to include the TextFrame that represents this row (the queried one)
|
||||
.take(logical_row + 1)
|
||||
.fold(0, |acc, text_frame| acc + text_frame.lines().len())
|
||||
// subtracting one to convert a count of rows into an index
|
||||
- 1
|
||||
}
|
||||
|
||||
/// Returns the line at the given index, where the first line is 0 if it exists.
|
||||
pub fn get_line(&self, desired_index: usize) -> Option<&text_layout::Line> {
|
||||
self.frames
|
||||
.iter()
|
||||
.flat_map(|frame| frame.lines())
|
||||
.nth(desired_index)
|
||||
}
|
||||
|
||||
/// Given a `DisplayPoint`, converts the row number such that soft wrapping
|
||||
/// is considered. This function will yield a result in the case where the
|
||||
/// provided point is within the bounds of the last laid out text. Otherwise,
|
||||
/// it will return `None`.
|
||||
pub fn to_soft_wrap_point(
|
||||
&self,
|
||||
point: DisplayPoint,
|
||||
clamp_direction: ClampDirection,
|
||||
) -> Option<SoftWrapPoint> {
|
||||
let rows_before_frame: u32 = self
|
||||
.frames
|
||||
.iter()
|
||||
.take(point.row() as usize)
|
||||
.fold(0, |acc, text_frame| acc + text_frame.lines().len() as u32);
|
||||
|
||||
let frame = self.frames.get(point.row() as usize)?;
|
||||
|
||||
let rows_within_frame = frame.row_within_frame(
|
||||
point.column() as usize,
|
||||
matches!(clamp_direction, ClampDirection::Up),
|
||||
) as u32;
|
||||
Some(SoftWrapPoint::new(
|
||||
rows_before_frame + rows_within_frame,
|
||||
point.column(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Given a `SoftWrapPoint`, converts the row number to a value irrespective
|
||||
/// of soft-wrapping.
|
||||
pub fn to_display_point(&self, point: SoftWrapPoint) -> DisplayPointAndClampDirection {
|
||||
let mut acc_lines = 0;
|
||||
let mut row = 0;
|
||||
let mut clamp_direction = Default::default();
|
||||
for (i, frame) in self.frames.iter().enumerate() {
|
||||
if acc_lines + frame.lines().len() > point.row() as usize {
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
acc_lines += frame.lines().len()
|
||||
}
|
||||
if let Some(line) = self.get_line(point.row() as usize) {
|
||||
if let Some(glyph) = line.last_glyph() {
|
||||
if point.column() as usize == glyph.index + 1 {
|
||||
clamp_direction = ClampDirection::Up;
|
||||
}
|
||||
} else if let Some(glyph) = line.first_glyph() {
|
||||
if point.column() as usize == glyph.index {
|
||||
clamp_direction = ClampDirection::Down;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisplayPointAndClampDirection {
|
||||
point: DisplayPoint::new(row as u32, point.column()),
|
||||
clamp_direction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DisplayPointAndClampDirection {
|
||||
pub point: DisplayPoint,
|
||||
pub clamp_direction: ClampDirection,
|
||||
}
|
||||
|
||||
pub struct FrameLayoutDisplayedLines<'a> {
|
||||
frames: &'a FrameLayouts,
|
||||
current_row: usize,
|
||||
current_line_of_row: usize,
|
||||
lines_yielded: u32,
|
||||
}
|
||||
|
||||
impl<'a> FrameLayoutDisplayedLines<'a> {
|
||||
pub fn zero(frames: &'a FrameLayouts) -> Self {
|
||||
Self {
|
||||
frames,
|
||||
current_row: 0,
|
||||
current_line_of_row: 0,
|
||||
lines_yielded: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for FrameLayoutDisplayedLines<'a> {
|
||||
type Item = &'a text_layout::Line;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.lines_yielded >= self.frames.end_line {
|
||||
return None;
|
||||
}
|
||||
if let Some(frame) = self.frames.frames.get(self.current_row) {
|
||||
if frame.lines().len() > self.current_line_of_row {
|
||||
let line = &frame.lines()[self.current_line_of_row];
|
||||
self.current_line_of_row += 1;
|
||||
self.lines_yielded += 1;
|
||||
Some(line)
|
||||
} else {
|
||||
self.current_row += 1;
|
||||
self.current_line_of_row = 0;
|
||||
self.next()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "soft_wrap_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,75 @@
|
||||
use super::*;
|
||||
use vec1::vec1;
|
||||
|
||||
#[test]
|
||||
fn test_singleton_frames_displayed_lines() {
|
||||
let frame_layouts = FrameLayouts {
|
||||
frames: vec![
|
||||
Arc::new(text_layout::TextFrame::empty(12., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(13., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(14., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(15., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(16., 1.)),
|
||||
],
|
||||
start_line: 2,
|
||||
end_line: 3,
|
||||
};
|
||||
|
||||
assert_eq!(frame_layouts.displayed_lines().count(), 1);
|
||||
|
||||
let mut iter = frame_layouts.displayed_lines();
|
||||
assert_eq!(iter.next().expect("Should have line").font_size, 14.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_displayed_lines_end_line_greater_than_iterator_size() {
|
||||
let frame_layouts = FrameLayouts {
|
||||
frames: vec![
|
||||
Arc::new(text_layout::TextFrame::empty(12., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(13., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(14., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(15., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(16., 1.)),
|
||||
],
|
||||
start_line: 3,
|
||||
end_line: 6,
|
||||
};
|
||||
|
||||
assert_eq!(frame_layouts.displayed_lines().count(), 2);
|
||||
|
||||
let mut iter = frame_layouts.displayed_lines();
|
||||
assert_eq!(iter.next().expect("Should have line").font_size, 15.);
|
||||
assert_eq!(iter.next().expect("Should have line").font_size, 16.);
|
||||
assert!(iter.next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_soft_wrapped_frame_displayed_lines() {
|
||||
let frame_layouts = FrameLayouts {
|
||||
frames: vec![
|
||||
Arc::new(text_layout::TextFrame::new(
|
||||
vec1![
|
||||
text_layout::Line::empty(10., 1., 0),
|
||||
text_layout::Line::empty(11., 1., 1),
|
||||
text_layout::Line::empty(12., 1., 2),
|
||||
],
|
||||
0.,
|
||||
Default::default(),
|
||||
)),
|
||||
Arc::new(text_layout::TextFrame::empty(13., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(14., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(15., 1.)),
|
||||
Arc::new(text_layout::TextFrame::empty(16., 1.)),
|
||||
],
|
||||
start_line: 2,
|
||||
end_line: 5,
|
||||
};
|
||||
|
||||
assert_eq!(frame_layouts.displayed_lines().count(), 3);
|
||||
|
||||
let mut iter = frame_layouts.displayed_lines();
|
||||
assert_eq!(iter.next().expect("Should have line").font_size, 12.);
|
||||
assert_eq!(iter.next().expect("Should have line").font_size, 13.);
|
||||
assert_eq!(iter.next().expect("Should have line").font_size, 14.);
|
||||
assert!(iter.next().is_none());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
use super::EditorElement;
|
||||
|
||||
const FLOAT_TOLERANCE: f32 = 1e-4;
|
||||
|
||||
#[test]
|
||||
fn scroll_position_y_fract_is_continuous_at_first_visible_row_boundary_without_top_section() {
|
||||
let line_height = 20.0;
|
||||
|
||||
let fract_before_boundary = EditorElement::scroll_position_y_fract(0.99, line_height, 0.0);
|
||||
assert!((fract_before_boundary - 19.8).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
let fract_at_boundary = EditorElement::scroll_position_y_fract(1.0, line_height, 0.0);
|
||||
assert!((fract_at_boundary - 0.0).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_position_y_fract_is_continuous_at_first_visible_row_boundary_with_top_section() {
|
||||
let line_height = 20.0;
|
||||
let top_section_height_px = 10.0;
|
||||
|
||||
let fract_before_boundary =
|
||||
EditorElement::scroll_position_y_fract(1.49, line_height, top_section_height_px);
|
||||
assert!((fract_before_boundary - 29.8).abs() < FLOAT_TOLERANCE);
|
||||
|
||||
let fract_at_boundary =
|
||||
EditorElement::scroll_position_y_fract(1.5, line_height, top_section_height_px);
|
||||
assert!((fract_at_boundary - 0.0).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_position_y_fract_tracks_fractional_offset_after_boundary() {
|
||||
let line_height = 20.0;
|
||||
let top_section_height_px = 10.0;
|
||||
|
||||
let fract = EditorElement::scroll_position_y_fract(1.75, line_height, top_section_height_px);
|
||||
assert!((fract - 5.0).abs() < FLOAT_TOLERANCE);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,42 @@
|
||||
/// Returns `true` if `bytes` is a PNG whose `tEXt` metadata contains `Software: Figma`.
|
||||
///
|
||||
/// Figma exports PNGs with a `tEXt` chunk where the keyword is `Software` and the value
|
||||
/// is `Figma`. We scan the raw chunk stream for this marker without pulling in an image
|
||||
/// parsing library, keeping the check lightweight.
|
||||
const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||
|
||||
pub fn is_figma_png(bytes: &[u8]) -> bool {
|
||||
if !bytes.starts_with(PNG_SIGNATURE) {
|
||||
return false;
|
||||
}
|
||||
let mut offset = 8usize;
|
||||
while offset + 12 <= bytes.len() {
|
||||
let length = u32::from_be_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
]) as usize;
|
||||
let type_start = offset + 4;
|
||||
let data_start = type_start + 4;
|
||||
let Some(data_end) = data_start.checked_add(length) else {
|
||||
break;
|
||||
};
|
||||
if data_end + 4 > bytes.len() {
|
||||
break;
|
||||
}
|
||||
// tEXt chunk: data is `keyword\0text`
|
||||
if &bytes[type_start..data_start] == b"tEXt"
|
||||
&& bytes[data_start..data_end].starts_with(b"Software\x00Figma")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Advance past: length(4) + type(4) + data(length) + CRC(4)
|
||||
offset = data_end + 4;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "is_figma_png_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,85 @@
|
||||
use super::is_figma_png;
|
||||
|
||||
fn build_png_with_text_chunk(keyword: &[u8], text: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
let data: Vec<u8> = keyword.iter().chain(b"\x00").chain(text).copied().collect();
|
||||
let length = data.len() as u32;
|
||||
bytes.extend_from_slice(&length.to_be_bytes());
|
||||
bytes.extend_from_slice(b"tEXt");
|
||||
bytes.extend_from_slice(&data);
|
||||
bytes.extend_from_slice(&[0u8; 4]); // fake CRC
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_true_for_figma_export_png() {
|
||||
let bytes = include_bytes!("figma-export.png");
|
||||
assert!(is_figma_png(bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_false_for_non_figma_png() {
|
||||
let bytes = include_bytes!("non-figma-export.png");
|
||||
assert!(!is_figma_png(bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_false_for_empty_bytes() {
|
||||
assert!(!is_figma_png(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_false_for_invalid_png_signature() {
|
||||
let mut bytes = b"\x00PNG\r\n\x1a\n".to_vec();
|
||||
bytes.extend_from_slice(&[0u8; 12]);
|
||||
assert!(!is_figma_png(&bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_true_for_crafted_png_with_software_figma() {
|
||||
let bytes = build_png_with_text_chunk(b"Software", b"Figma");
|
||||
assert!(is_figma_png(&bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_false_when_text_chunk_keyword_is_not_software() {
|
||||
let bytes = build_png_with_text_chunk(b"Author", b"Figma");
|
||||
assert!(!is_figma_png(&bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_false_when_software_value_is_not_figma() {
|
||||
let bytes = build_png_with_text_chunk(b"Software", b"Sketch");
|
||||
assert!(!is_figma_png(&bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_true_when_figma_text_chunk_follows_another_chunk() {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
// A preceding chunk (e.g. tIME)
|
||||
let preceding = b"dummy data";
|
||||
bytes.extend_from_slice(&(preceding.len() as u32).to_be_bytes());
|
||||
bytes.extend_from_slice(b"tIME");
|
||||
bytes.extend_from_slice(preceding);
|
||||
bytes.extend_from_slice(&[0u8; 4]); // fake CRC
|
||||
// tEXt chunk with Software: Figma
|
||||
let text_data = b"Software\x00Figma";
|
||||
bytes.extend_from_slice(&(text_data.len() as u32).to_be_bytes());
|
||||
bytes.extend_from_slice(b"tEXt");
|
||||
bytes.extend_from_slice(text_data);
|
||||
bytes.extend_from_slice(&[0u8; 4]); // fake CRC
|
||||
assert!(is_figma_png(&bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_false_for_truncated_chunk_data() {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
// Declare length as 100 but provide fewer bytes
|
||||
bytes.extend_from_slice(&100u32.to_be_bytes());
|
||||
bytes.extend_from_slice(b"tEXt");
|
||||
bytes.extend_from_slice(b"Software\x00Figma"); // only 15 bytes, not 100
|
||||
assert!(!is_figma_png(&bytes));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod is_figma_png;
|
||||
|
||||
pub use is_figma_png::is_figma_png;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,192 @@
|
||||
use vim::vim::VimMode;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{keymap::Keystroke, platform::WindowStyle, App};
|
||||
|
||||
use crate::editor::{DisplayPoint, EditorOptions, EditorView};
|
||||
|
||||
use super::initialize_app;
|
||||
|
||||
#[test]
|
||||
fn test_set_marked_text() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
|
||||
|
||||
app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let mut editor = EditorView::new_with_base_text("", Default::default(), ctx);
|
||||
|
||||
// Simulate typing in "nihao" into the IME and then selecting "你好" as the candidate.
|
||||
editor.set_marked_text("nihao", &(5..5), ctx);
|
||||
assert_eq!(editor.selected_text(ctx), "nihao");
|
||||
editor.ime_commit("你好", ctx);
|
||||
assert_eq!(editor.buffer_text(ctx), "你好");
|
||||
|
||||
editor.user_insert(", I am Teddy ", ctx);
|
||||
assert_eq!(editor.buffer_text(ctx), "你好, I am Teddy ".to_owned());
|
||||
|
||||
// Simulate typing in "xiong" into the IME and selecting "熊" as the candidate.
|
||||
editor.set_marked_text("xiong", &(5..5), ctx);
|
||||
assert_eq!(editor.selected_text(ctx), "xiong");
|
||||
editor.ime_commit("熊", ctx);
|
||||
assert_eq!(editor.buffer_text(ctx), "你好, I am Teddy 熊".to_owned());
|
||||
|
||||
editor
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_marked_text_multiple_empty_selections() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
|
||||
|
||||
app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let mut editor = EditorView::new_with_base_text(" is ", Default::default(), ctx);
|
||||
|
||||
// Set two cursors: one at the beginning and one at the end.
|
||||
editor
|
||||
.select_ranges(
|
||||
vec![
|
||||
DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0),
|
||||
DisplayPoint::new(0, 4)..DisplayPoint::new(0, 4),
|
||||
],
|
||||
ctx,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(editor.selections(ctx).len(), 2);
|
||||
|
||||
// Simulate typing in "pyaar" into the IME and then selecting "प्यार" as the candidate.
|
||||
editor.set_marked_text("pyaar", &(5..5), ctx);
|
||||
for selected_text in editor.selected_text_strings(ctx).iter() {
|
||||
assert_eq!(selected_text, "pyaar");
|
||||
}
|
||||
editor.ime_commit("प्यार", ctx);
|
||||
assert_eq!(editor.buffer_text(ctx), "प्यार is प्यार".to_owned());
|
||||
|
||||
editor
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_marked_text_multiple_nonempty_selections() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
|
||||
|
||||
app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let mut editor =
|
||||
EditorView::new_with_base_text("love is love", Default::default(), ctx);
|
||||
|
||||
// Select both instances of "love" in the buffer text.
|
||||
editor
|
||||
.select_ranges(
|
||||
vec![
|
||||
DisplayPoint::new(0, 0)..DisplayPoint::new(0, 4),
|
||||
DisplayPoint::new(0, 8)..DisplayPoint::new(0, 12),
|
||||
],
|
||||
ctx,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(editor.selections(ctx).len(), 2);
|
||||
|
||||
// Simulate typing in "pyaar" into the IME and then selecting "प्यार" as the candidate.
|
||||
editor.set_marked_text("pyaar", &(5..5), ctx);
|
||||
for selected_text in editor.selected_text_strings(ctx).iter() {
|
||||
assert_eq!(selected_text, "pyaar");
|
||||
}
|
||||
editor.ime_commit("प्यार", ctx);
|
||||
assert_eq!(editor.buffer_text(ctx), "प्यार is प्यार".to_owned());
|
||||
|
||||
editor
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_marked_text_vim_normal_mode() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
|
||||
|
||||
app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let editor_options = EditorOptions {
|
||||
supports_vim_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut editor = EditorView::new_with_base_text(
|
||||
"This text should remain unchanged",
|
||||
editor_options,
|
||||
ctx,
|
||||
);
|
||||
|
||||
editor
|
||||
.select_ranges(vec![DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)], ctx)
|
||||
.unwrap();
|
||||
|
||||
// Set vim to normal mode.
|
||||
editor.vim_keystroke(&Keystroke::parse("escape").unwrap(), ctx);
|
||||
assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal));
|
||||
|
||||
// Simulate typing in "Om Shanti Om" into the IME and then selecting "ॐ शांति ॐ" as the candidate.
|
||||
// Since we're in normal mode, we don't expect the text to change at all.
|
||||
editor.set_marked_text("om shanti om", &(10..10), ctx);
|
||||
assert_eq!(editor.selected_text(ctx), "");
|
||||
assert_eq!(
|
||||
editor.buffer_text(ctx),
|
||||
"This text should remain unchanged".to_owned()
|
||||
);
|
||||
editor.ime_commit("ॐ शांति ॐ", ctx);
|
||||
assert_eq!(
|
||||
editor.buffer_text(ctx),
|
||||
"This text should remain unchanged".to_owned()
|
||||
);
|
||||
|
||||
editor
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_marked_text_vim_insert_mode() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
|
||||
|
||||
app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let editor_options = EditorOptions {
|
||||
supports_vim_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut editor = EditorView::new_with_base_text(
|
||||
" is the best Bollywood movie ever created.",
|
||||
editor_options,
|
||||
ctx,
|
||||
);
|
||||
|
||||
editor
|
||||
.select_ranges(vec![DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)], ctx)
|
||||
.unwrap();
|
||||
|
||||
// Set vim to normal mode.
|
||||
assert_eq!(editor.vim_mode(ctx), Some(VimMode::Insert));
|
||||
|
||||
// Simulate typing in "Om Shanti Om" into the IME and then selecting "ॐ शांति ॐ" as the candidate.
|
||||
// Since we're in insert mode, we don't expect the text to be inserted.
|
||||
editor.set_marked_text("om shanti om", &(10..10), ctx);
|
||||
assert_eq!(editor.selected_text(ctx), "om shanti om");
|
||||
assert_eq!(
|
||||
editor.buffer_text(ctx),
|
||||
"om shanti om is the best Bollywood movie ever created.".to_owned()
|
||||
);
|
||||
editor.ime_commit("ॐ शांति ॐ", ctx);
|
||||
assert_eq!(
|
||||
editor.buffer_text(ctx),
|
||||
"ॐ शांति ॐ is the best Bollywood movie ever created.".to_owned()
|
||||
);
|
||||
|
||||
editor
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
use super::{time, Buffer};
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::ops::Range;
|
||||
use string_offset::CharOffset;
|
||||
use time::Lamport;
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug, Hash, Serialize, Deserialize)]
|
||||
pub enum Anchor {
|
||||
Start,
|
||||
End,
|
||||
Middle {
|
||||
insertion_id: Lamport,
|
||||
offset: CharOffset,
|
||||
bias: AnchorBias,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug, Hash, Serialize, Deserialize)]
|
||||
pub enum AnchorBias {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl PartialOrd for AnchorBias {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for AnchorBias {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
use AnchorBias::*;
|
||||
|
||||
if self == other {
|
||||
return Ordering::Equal;
|
||||
}
|
||||
|
||||
match (self, other) {
|
||||
(Left, _) => Ordering::Less,
|
||||
(Right, _) => Ordering::Greater,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
pub fn cmp(&self, other: &Anchor, buffer: &Buffer) -> Result<Ordering> {
|
||||
if self == other {
|
||||
return Ok(Ordering::Equal);
|
||||
}
|
||||
|
||||
Ok(match (self, other) {
|
||||
(Anchor::Start, _) | (_, Anchor::End) => Ordering::Less,
|
||||
(Anchor::End, _) | (_, Anchor::Start) => Ordering::Greater,
|
||||
(
|
||||
Anchor::Middle {
|
||||
offset: self_offset,
|
||||
bias: self_bias,
|
||||
..
|
||||
},
|
||||
Anchor::Middle {
|
||||
offset: other_offset,
|
||||
bias: other_bias,
|
||||
..
|
||||
},
|
||||
) => buffer
|
||||
.fragment_id_for_anchor(self)?
|
||||
.cmp(buffer.fragment_id_for_anchor(other)?)
|
||||
.then_with(|| self_offset.cmp(other_offset))
|
||||
.then_with(|| self_bias.cmp(other_bias)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn observed(&self, buffer: &Buffer) -> bool {
|
||||
match self {
|
||||
Anchor::Start | Anchor::End => true,
|
||||
Anchor::Middle { insertion_id, .. } => buffer.versions().observed(insertion_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AnchorRangeExt {
|
||||
fn cmp(&self, b: &Range<Anchor>, buffer: &Buffer) -> Result<Ordering>;
|
||||
}
|
||||
|
||||
impl AnchorRangeExt for Range<Anchor> {
|
||||
fn cmp(&self, other: &Range<Anchor>, buffer: &Buffer) -> Result<Ordering> {
|
||||
Ok(match self.start.cmp(&other.start, buffer)? {
|
||||
Ordering::Equal => other.end.cmp(&self.end, buffer)?,
|
||||
ord => ord,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use super::time::ReplicaId;
|
||||
use super::Operation;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// An operation queue to defer buffer edits
|
||||
/// that cannot yet be applied.
|
||||
#[cfg_attr(test, derive(Clone))]
|
||||
pub struct DeferredOperations {
|
||||
/// The set of replica IDs for which operations
|
||||
/// are being deferred.
|
||||
replica_ids: HashSet<ReplicaId>,
|
||||
|
||||
/// The set of operations that are being deferred.
|
||||
///
|
||||
/// This list must stay ordered by the lamport timestamp
|
||||
/// of the edits to avoid starvation. Specifically,
|
||||
/// if edit B is causally dependent on edit A, then
|
||||
/// lamport(B) > lamport(A). So if the operations are
|
||||
/// processed in order, then consumers can guarantee that causally
|
||||
/// dependent, deferred ops will not be starved (even if they
|
||||
/// are very backed up). On the other hand, if edit B is _not_
|
||||
/// causally dependent on edit A, then it doesn't matter whether
|
||||
/// we process edit A or edit B first.
|
||||
///
|
||||
/// This ordering invariant allows consumers to [`Self::drain`] once
|
||||
/// rather than repeatedly drain and apply.
|
||||
operations: Vec<Operation>,
|
||||
}
|
||||
|
||||
impl DeferredOperations {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
replica_ids: HashSet::new(),
|
||||
operations: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Empties the operation queue, returning an ordered
|
||||
/// vector of operations that were previously deferred.
|
||||
pub fn drain(&mut self) -> Vec<Operation> {
|
||||
self.replica_ids = HashSet::new();
|
||||
std::mem::take(&mut self.operations)
|
||||
}
|
||||
|
||||
/// Extends the set of operations that need to be deferred.
|
||||
///
|
||||
/// There is intentionally no `push` API for a single element.
|
||||
/// Callers are encouraged to collect the operations that need to
|
||||
/// deferred and batch-push them because extending the operation queue
|
||||
/// is expensive.
|
||||
pub fn extend(&mut self, operations: Vec<Operation>) {
|
||||
for op in operations {
|
||||
self.replica_ids.insert(op.replica_id().clone());
|
||||
self.operations.push(op);
|
||||
}
|
||||
self.operations
|
||||
.sort_unstable_by_key(|op| op.lamport_timestamp().clone());
|
||||
}
|
||||
|
||||
/// Returns true iff there are operations in the queue that originated from `replica_id`.
|
||||
pub fn replica_deferred(&self, replica_id: &ReplicaId) -> bool {
|
||||
self.replica_ids.contains(replica_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "deferred_ops_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,73 @@
|
||||
use crate::editor::view::model::buffer::time::ReplicaId;
|
||||
use crate::editor::view::model::buffer::EditOperation;
|
||||
|
||||
use super::super::time::{Global, Lamport};
|
||||
use super::{DeferredOperations, Operation};
|
||||
use itertools::Itertools;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
fn edit_operation(lamport: Lamport) -> Operation {
|
||||
Operation::Edit(EditOperation {
|
||||
lamport_timestamp: lamport.clone(),
|
||||
versions: Global::new(),
|
||||
start_id: lamport.clone(),
|
||||
start_character_offset: CharOffset::from(0),
|
||||
end_id: lamport,
|
||||
end_character_offset: CharOffset::from(0),
|
||||
new_text: String::from(""),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ordering() {
|
||||
let mut ops = DeferredOperations::new();
|
||||
let edits = vec![
|
||||
edit_operation(Lamport {
|
||||
replica_id: ReplicaId::new(1),
|
||||
value: 10.into(),
|
||||
}),
|
||||
edit_operation(Lamport {
|
||||
replica_id: ReplicaId::new(5),
|
||||
value: 2.into(),
|
||||
}),
|
||||
edit_operation(Lamport {
|
||||
replica_id: ReplicaId::new(3),
|
||||
value: 20.into(),
|
||||
}),
|
||||
edit_operation(Lamport {
|
||||
replica_id: ReplicaId::new(9),
|
||||
value: 30.into(),
|
||||
}),
|
||||
edit_operation(Lamport {
|
||||
replica_id: ReplicaId::new(2),
|
||||
value: 1.into(),
|
||||
}),
|
||||
edit_operation(Lamport {
|
||||
replica_id: ReplicaId::new(1),
|
||||
value: 2.into(),
|
||||
}),
|
||||
];
|
||||
ops.extend(edits.clone());
|
||||
|
||||
let drained = ops.drain();
|
||||
let expected = edits
|
||||
.into_iter()
|
||||
.sorted_by_key(|k| k.lamport_timestamp().clone())
|
||||
.collect_vec();
|
||||
assert_eq!(drained, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replica_deferred() {
|
||||
let mut ops = DeferredOperations::new();
|
||||
let replica_id = ReplicaId::new(1);
|
||||
assert!(!ops.replica_deferred(&replica_id));
|
||||
|
||||
ops.extend(vec![edit_operation(Lamport {
|
||||
replica_id: replica_id.clone(),
|
||||
value: 0.into(),
|
||||
})]);
|
||||
|
||||
assert!(ops.replica_deferred(&replica_id));
|
||||
assert!(!ops.replica_deferred(&ReplicaId::new(2)));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
use super::{CharOffset, Point};
|
||||
use itertools::Either;
|
||||
use std::iter::Peekable;
|
||||
use warpui::text::{
|
||||
word_boundaries::WordBoundariesApproach, words::is_subword_boundary_char, TextBuffer,
|
||||
};
|
||||
|
||||
pub struct SubwordBoundaries<'a, T: TextBuffer + ?Sized> {
|
||||
offset: CharOffset,
|
||||
chars: Peekable<Either<T::Chars<'a>, T::CharsReverse<'a>>>,
|
||||
char_window: CharWindow,
|
||||
buffer: &'a T,
|
||||
approach: WordBoundariesApproach,
|
||||
in_word: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl<'a, T: TextBuffer + ?Sized> SubwordBoundaries<'a, T> {
|
||||
/// Returns an iterator for the beginnings of the subwords in the buffer.
|
||||
pub fn forward_subword_starts(offset: CharOffset, chars: T::Chars<'a>, buffer: &'a T) -> Self {
|
||||
let mut peekable_chars = Either::Left(chars).peekable();
|
||||
let first = peekable_chars.next();
|
||||
let second = peekable_chars.next();
|
||||
let third = peekable_chars.next();
|
||||
Self {
|
||||
offset,
|
||||
buffer,
|
||||
chars: peekable_chars,
|
||||
char_window: CharWindow::new(first, second, third),
|
||||
in_word: true,
|
||||
approach: WordBoundariesApproach::ForwardWordStarts,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator for the ends of the subwords in the buffer.
|
||||
pub fn forward_subword_ends_exclusive(
|
||||
offset: CharOffset,
|
||||
chars: T::Chars<'a>,
|
||||
buffer: &'a T,
|
||||
) -> Self {
|
||||
let mut peekable_chars = Either::Left(chars).peekable();
|
||||
let first = peekable_chars.next();
|
||||
let second = peekable_chars.next();
|
||||
let third = peekable_chars.next();
|
||||
Self {
|
||||
offset,
|
||||
buffer,
|
||||
chars: peekable_chars,
|
||||
char_window: CharWindow::new(first, second, third),
|
||||
in_word: false,
|
||||
approach: WordBoundariesApproach::ForwardWordEnds,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a backwards iterator for the beginning of subwords in the buffer.
|
||||
pub fn backward_subword_starts_exclusive(
|
||||
offset: CharOffset,
|
||||
chars: T::CharsReverse<'a>,
|
||||
buffer: &'a T,
|
||||
) -> Self {
|
||||
let mut peekable_chars = Either::Right(chars).peekable();
|
||||
let first = peekable_chars.next();
|
||||
let second = peekable_chars.next();
|
||||
let third = peekable_chars.next();
|
||||
Self {
|
||||
offset,
|
||||
buffer,
|
||||
chars: peekable_chars,
|
||||
char_window: CharWindow::new(first, second, third),
|
||||
in_word: false,
|
||||
approach: WordBoundariesApproach::BackwardWordStarts,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the next character and update the offset.
|
||||
fn step(&mut self) {
|
||||
let new_char = self.chars.next();
|
||||
self.char_window.forward(new_char.to_owned());
|
||||
|
||||
match self.approach {
|
||||
WordBoundariesApproach::ForwardWordStarts | WordBoundariesApproach::ForwardWordEnds => {
|
||||
self.offset += 1;
|
||||
}
|
||||
WordBoundariesApproach::BackwardWordStarts => {
|
||||
self.offset -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for the `Iterator::next()` implementation.
|
||||
/// Moves forward through the string and returns the start of the next
|
||||
/// subword.
|
||||
fn next_forward_starts(&mut self) -> Option<<Self as Iterator>::Item> {
|
||||
while let Some(c) = self.char_window.first() {
|
||||
if self.in_word {
|
||||
// We're in a word, but are we in a subword?
|
||||
if is_subword_boundary_char(c) {
|
||||
// c isn't part of a subword.
|
||||
self.in_word = false;
|
||||
self.step();
|
||||
} else if c.is_uppercase() {
|
||||
if let Some(c_next) = self.char_window.second() {
|
||||
// If c is upper and c_next is lower, then
|
||||
// c marks the start of a Capitalized word.
|
||||
if c_next.is_lowercase() {
|
||||
let point = self.buffer.to_point(self.offset).ok();
|
||||
self.step(); // to avoid getting stuck
|
||||
return point;
|
||||
} else if c_next.is_uppercase() {
|
||||
// peek once more, to see if c is the "X" in "XYz"
|
||||
if let Some(_c_next_next) = self.char_window.third() {
|
||||
self.step();
|
||||
} else {
|
||||
// we've reached the last character in the string,
|
||||
// and since it's uppercase it should be treated
|
||||
// as the start of a one-letter word.
|
||||
self.step();
|
||||
return self.buffer.to_point(self.offset).ok();
|
||||
}
|
||||
} else {
|
||||
self.step();
|
||||
}
|
||||
} else {
|
||||
// c_next wasn't available.
|
||||
self.step();
|
||||
}
|
||||
} else if c.is_lowercase() {
|
||||
self.step();
|
||||
|
||||
if let Some(c_next) = self.char_window.first() {
|
||||
if c_next.is_uppercase() {
|
||||
let point = self.buffer.to_point(self.offset).ok();
|
||||
self.step();
|
||||
return point;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.step();
|
||||
}
|
||||
|
||||
// Still haven't entered a word.
|
||||
} else if is_subword_boundary_char(c) {
|
||||
self.step();
|
||||
|
||||
// Just entered a word.
|
||||
} else {
|
||||
self.in_word = true;
|
||||
let point = self.buffer.to_point(self.offset).ok();
|
||||
self.step();
|
||||
return point;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Helper for the `Iterator::next()` implementation.
|
||||
/// Moves forward through the string and returns the end of the next
|
||||
/// subword.
|
||||
fn next_forward_ends(&mut self) -> Option<<Self as Iterator>::Item> {
|
||||
while let Some(c) = self.char_window.first() {
|
||||
if self.in_word && is_subword_boundary_char(c) {
|
||||
// We are in a word, but the next character is _not_ in a word,
|
||||
// so we have found the boundary.
|
||||
self.in_word = false;
|
||||
return self.buffer.to_point(self.offset).ok();
|
||||
}
|
||||
|
||||
if !self.in_word && !is_subword_boundary_char(c) {
|
||||
self.in_word = true;
|
||||
}
|
||||
|
||||
if self.in_word {
|
||||
if let Some(c_next) = self.char_window.second() {
|
||||
if c.is_lowercase() && c_next.is_uppercase() {
|
||||
// c is the end of a word, and c_next begins the next word.
|
||||
self.step();
|
||||
return self.buffer.to_point(self.offset).ok();
|
||||
} else if c.is_uppercase() && c_next.is_uppercase() {
|
||||
// c_next could be part of an all-caps word
|
||||
// or the start of a new Capitalized word
|
||||
if let Some(c_next_next) = self.char_window.third() {
|
||||
if c_next_next.is_lowercase() {
|
||||
self.step();
|
||||
return self.buffer.to_point(self.offset).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.step();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Helper for the `Iterator::next()` implementation.
|
||||
/// Moves backward through the string and returns the start of the closest
|
||||
/// subword.
|
||||
fn next_backward_starts(&mut self) -> Option<<Self as Iterator>::Item> {
|
||||
while let Some(c) = self.char_window.first() {
|
||||
if self.in_word && is_subword_boundary_char(c) {
|
||||
self.in_word = false;
|
||||
let point = self.buffer.to_point(self.offset).ok();
|
||||
self.step();
|
||||
return point;
|
||||
}
|
||||
|
||||
if !self.in_word && !is_subword_boundary_char(c) {
|
||||
self.in_word = true;
|
||||
}
|
||||
|
||||
if self.in_word {
|
||||
if let Some(c_next) = self.char_window.second() {
|
||||
if c.is_lowercase() && c_next.is_uppercase() {
|
||||
// c_next is the start of the c's subword.
|
||||
self.step();
|
||||
self.step();
|
||||
let point = self.buffer.to_point(self.offset).ok();
|
||||
self.step(); // to avoid re-designating this character as a start
|
||||
return point;
|
||||
} else if c.is_uppercase() && c_next.is_lowercase() {
|
||||
// c is the start Capitalized or Uppercase subword,
|
||||
// and c_next is the end of a Capitalized or lowercase
|
||||
// subword to the right of it.
|
||||
self.step();
|
||||
let point = self.buffer.to_point(self.offset).ok();
|
||||
return point;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.step();
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: TextBuffer + ?Sized> Iterator for SubwordBoundaries<'_, T> {
|
||||
type Item = Point;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(point) = match self.approach {
|
||||
WordBoundariesApproach::ForwardWordStarts => self.next_forward_starts(),
|
||||
WordBoundariesApproach::ForwardWordEnds => self.next_forward_ends(),
|
||||
WordBoundariesApproach::BackwardWordStarts => self.next_backward_starts(),
|
||||
} {
|
||||
return Some(point);
|
||||
}
|
||||
|
||||
// We have consumed all of the characters in the given direction. However, we should also
|
||||
// treat the end (or beginning if backward) of the buffer as a word boundary. We only want
|
||||
// to return that once, however, so we mark ourselves as done afterwards.
|
||||
if self.done {
|
||||
None
|
||||
} else {
|
||||
self.done = true;
|
||||
|
||||
self.buffer.to_point(self.offset).ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage for characters from the buffer, used by the `SubwordBoundaries`
|
||||
/// iterator to find the start and end of subwords.
|
||||
struct CharWindow {
|
||||
/// A store of characters retreived from the `chars` iterator.
|
||||
///
|
||||
/// `char_window[0]`: character at the current offset.
|
||||
///
|
||||
/// `char_window[1]`: character after the current offset.
|
||||
///
|
||||
/// `char_window[2]`: two characters after the current offset.
|
||||
window: Vec<Option<char>>,
|
||||
}
|
||||
|
||||
impl CharWindow {
|
||||
fn new(first: Option<char>, second: Option<char>, third: Option<char>) -> Self {
|
||||
Self {
|
||||
window: vec![first, second, third],
|
||||
}
|
||||
}
|
||||
|
||||
fn forward(&mut self, new_char: Option<char>) {
|
||||
self.window.remove(0);
|
||||
self.window.push(new_char);
|
||||
}
|
||||
|
||||
fn first(&self) -> Option<char> {
|
||||
self.window.first().unwrap_or(&None).to_owned()
|
||||
}
|
||||
|
||||
fn second(&self) -> Option<char> {
|
||||
self.window.get(1).unwrap_or(&None).to_owned()
|
||||
}
|
||||
|
||||
fn third(&self) -> Option<char> {
|
||||
self.window.get(2).unwrap_or(&None).to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "subword_boundaries_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,287 @@
|
||||
use super::super::Buffer;
|
||||
use warpui::text::point::Point;
|
||||
|
||||
#[test]
|
||||
fn test_subword_boundaries_forward_starts() {
|
||||
let mut buffer: Buffer;
|
||||
let mut starts: Vec<Point>;
|
||||
let mut starts_expected: Vec<Point>;
|
||||
|
||||
buffer = Buffer::new("snake_case");
|
||||
starts = buffer
|
||||
.subword_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 6), Point::new(0, 10)];
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("camelCase");
|
||||
starts = buffer
|
||||
.subword_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 5), Point::new(0, 9)];
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("ALetter");
|
||||
starts = buffer
|
||||
.subword_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 1), Point::new(0, 7)];
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("endWithA");
|
||||
starts = buffer
|
||||
.subword_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 3), Point::new(0, 7), Point::new(0, 8)];
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("ABcD");
|
||||
starts = buffer
|
||||
.subword_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 1), Point::new(0, 3), Point::new(0, 4)];
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("oneTwo_threeFour");
|
||||
starts = buffer
|
||||
.subword_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![
|
||||
Point::new(0, 3),
|
||||
Point::new(0, 7),
|
||||
Point::new(0, 12),
|
||||
Point::new(0, 16),
|
||||
];
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("s_hOrt_Word");
|
||||
starts = buffer
|
||||
.subword_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![
|
||||
Point::new(0, 2),
|
||||
Point::new(0, 3),
|
||||
Point::new(0, 7),
|
||||
Point::new(0, 11),
|
||||
];
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("test/c/ab/word_with_underscoresAndUHHCaps {восибing}");
|
||||
starts = buffer
|
||||
.subword_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![
|
||||
Point::new(0, 5),
|
||||
Point::new(0, 7),
|
||||
Point::new(0, 10),
|
||||
Point::new(0, 15),
|
||||
Point::new(0, 20),
|
||||
Point::new(0, 31),
|
||||
Point::new(0, 34),
|
||||
Point::new(0, 37),
|
||||
Point::new(0, 43),
|
||||
Point::new(0, 52),
|
||||
];
|
||||
assert_eq!(starts, starts_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subword_boundaries_forward_ends() {
|
||||
let mut buffer: Buffer;
|
||||
let mut ends: Vec<Point>;
|
||||
let mut ends_expected: Vec<Point>;
|
||||
|
||||
buffer = Buffer::new("snake_case");
|
||||
ends = buffer
|
||||
.subword_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
ends_expected = vec![Point::new(0, 5), Point::new(0, 10)];
|
||||
assert_eq!(ends, ends_expected);
|
||||
|
||||
buffer = Buffer::new("camelCase");
|
||||
ends = buffer
|
||||
.subword_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
ends_expected = vec![Point::new(0, 5), Point::new(0, 9)];
|
||||
assert_eq!(ends, ends_expected);
|
||||
|
||||
buffer = Buffer::new("ALetter");
|
||||
ends = buffer
|
||||
.subword_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
ends_expected = vec![Point::new(0, 1), Point::new(0, 7)];
|
||||
assert_eq!(ends, ends_expected);
|
||||
|
||||
buffer = Buffer::new("endWithA");
|
||||
ends = buffer
|
||||
.subword_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
ends_expected = vec![Point::new(0, 3), Point::new(0, 7), Point::new(0, 8)];
|
||||
assert_eq!(ends, ends_expected);
|
||||
|
||||
buffer = Buffer::new("ABcD");
|
||||
ends = buffer
|
||||
.subword_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
ends_expected = vec![Point::new(0, 1), Point::new(0, 3), Point::new(0, 4)];
|
||||
assert_eq!(ends, ends_expected);
|
||||
|
||||
buffer = Buffer::new("oneTwo_threeFour");
|
||||
ends = buffer
|
||||
.subword_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
ends_expected = vec![
|
||||
Point::new(0, 3),
|
||||
Point::new(0, 6),
|
||||
Point::new(0, 12),
|
||||
Point::new(0, 16),
|
||||
];
|
||||
assert_eq!(ends, ends_expected);
|
||||
|
||||
buffer = Buffer::new("s_hOrt_Word");
|
||||
ends = buffer
|
||||
.subword_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
ends_expected = vec![
|
||||
Point::new(0, 1),
|
||||
Point::new(0, 3),
|
||||
Point::new(0, 6),
|
||||
Point::new(0, 11),
|
||||
];
|
||||
assert_eq!(ends, ends_expected);
|
||||
|
||||
buffer = Buffer::new("test/c/ab/word_with_underscoresAndUHHCaps {восибing}");
|
||||
ends = buffer
|
||||
.subword_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
ends_expected = vec![
|
||||
Point::new(0, 4),
|
||||
Point::new(0, 6),
|
||||
Point::new(0, 9),
|
||||
Point::new(0, 14),
|
||||
Point::new(0, 19),
|
||||
Point::new(0, 31),
|
||||
Point::new(0, 34),
|
||||
Point::new(0, 37),
|
||||
Point::new(0, 41),
|
||||
Point::new(0, 51),
|
||||
Point::new(0, 52),
|
||||
];
|
||||
assert_eq!(ends, ends_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subword_boundaries_backward_starts() {
|
||||
let mut buffer: Buffer;
|
||||
let mut starts: Vec<Point>;
|
||||
let mut starts_expected: Vec<Point>;
|
||||
|
||||
buffer = Buffer::new("snake_case");
|
||||
starts = buffer
|
||||
.subword_backward_starts_from_offset_exclusive(Point::new(0, 10))
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 0), Point::new(0, 6)];
|
||||
starts_expected.reverse();
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("camelCase");
|
||||
starts = buffer
|
||||
.subword_backward_starts_from_offset_exclusive(Point::new(0, 9))
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 0), Point::new(0, 5)];
|
||||
starts_expected.reverse();
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("ALetter");
|
||||
starts = buffer
|
||||
.subword_backward_starts_from_offset_exclusive(Point::new(0, 7))
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 0), Point::new(0, 1)];
|
||||
starts_expected.reverse();
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("endWithA");
|
||||
starts = buffer
|
||||
.subword_backward_starts_from_offset_exclusive(Point::new(0, 8))
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 0), Point::new(0, 3), Point::new(0, 7)];
|
||||
starts_expected.reverse();
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("ABcD");
|
||||
starts = buffer
|
||||
.subword_backward_starts_from_offset_exclusive(Point::new(0, 4))
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![Point::new(0, 0), Point::new(0, 1), Point::new(0, 3)];
|
||||
starts_expected.reverse();
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("oneTwo_threeFour");
|
||||
starts = buffer
|
||||
.subword_backward_starts_from_offset_exclusive(Point::new(0, 16))
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![
|
||||
Point::new(0, 0),
|
||||
Point::new(0, 3),
|
||||
Point::new(0, 7),
|
||||
Point::new(0, 12),
|
||||
];
|
||||
starts_expected.reverse();
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("s_hOrt_Word");
|
||||
starts = buffer
|
||||
.subword_backward_starts_from_offset_exclusive(Point::new(0, 11))
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![
|
||||
Point::new(0, 0),
|
||||
Point::new(0, 2),
|
||||
Point::new(0, 3),
|
||||
Point::new(0, 7),
|
||||
];
|
||||
starts_expected.reverse();
|
||||
assert_eq!(starts, starts_expected);
|
||||
|
||||
buffer = Buffer::new("test/c/ab/word_with_underscoresAndUHHCaps {восибing}");
|
||||
starts = buffer
|
||||
.subword_backward_starts_from_offset_exclusive(Point::new(0, 52))
|
||||
.unwrap()
|
||||
.collect();
|
||||
starts_expected = vec![
|
||||
Point::new(0, 0),
|
||||
Point::new(0, 5),
|
||||
Point::new(0, 7),
|
||||
Point::new(0, 10),
|
||||
Point::new(0, 15),
|
||||
Point::new(0, 20),
|
||||
Point::new(0, 31),
|
||||
Point::new(0, 34),
|
||||
Point::new(0, 37),
|
||||
Point::new(0, 43),
|
||||
];
|
||||
starts_expected.reverse();
|
||||
assert_eq!(starts, starts_expected);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/// Test utilities for testing the buffer.
|
||||
use super::time::ReplicaId;
|
||||
use rand::Rng;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct Network<T: Clone> {
|
||||
inboxes: BTreeMap<ReplicaId, Vec<Envelope<T>>>,
|
||||
all_messages: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Envelope<T: Clone> {
|
||||
message: T,
|
||||
sender: ReplicaId,
|
||||
}
|
||||
|
||||
impl<T: Clone> Network<T> {
|
||||
pub fn new() -> Self {
|
||||
Network {
|
||||
inboxes: BTreeMap::new(),
|
||||
all_messages: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_peer(&mut self, id: ReplicaId) {
|
||||
self.inboxes.insert(id, Vec::new());
|
||||
}
|
||||
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.inboxes.values().all(|i| i.is_empty())
|
||||
}
|
||||
|
||||
pub fn broadcast<R>(&mut self, sender: ReplicaId, messages: Vec<T>, rng: &mut R)
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
for (replica, inbox) in self.inboxes.iter_mut() {
|
||||
if replica != &sender {
|
||||
for message in &messages {
|
||||
let min_index = inbox
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(index, envelope)| {
|
||||
if sender == envelope.sender {
|
||||
Some(index + 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Insert one or more duplicates of this message *after* the previous
|
||||
// message delivered by this replica.
|
||||
for _ in 0..rng.gen_range(1..4) {
|
||||
let insertion_index = rng.gen_range(min_index..inbox.len() + 1);
|
||||
inbox.insert(
|
||||
insertion_index,
|
||||
Envelope {
|
||||
message: message.clone(),
|
||||
sender: sender.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.all_messages.extend(messages);
|
||||
}
|
||||
|
||||
pub fn has_unreceived(&self, receiver: &ReplicaId) -> bool {
|
||||
!self.inboxes[receiver].is_empty()
|
||||
}
|
||||
|
||||
pub fn receive<R>(&mut self, receiver: ReplicaId, rng: &mut R) -> Vec<T>
|
||||
where
|
||||
R: Rng,
|
||||
{
|
||||
let inbox = self.inboxes.get_mut(&receiver).unwrap();
|
||||
let count = rng.gen_range(0..inbox.len() + 1);
|
||||
inbox
|
||||
.drain(0..count)
|
||||
.map(|envelope| envelope.message)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
use arrayvec::ArrayVec;
|
||||
use num_traits::SaturatingSub;
|
||||
use std::{
|
||||
cmp,
|
||||
fmt::{self, Debug},
|
||||
ops::{Bound, Index, Range, RangeBounds},
|
||||
rc::Rc,
|
||||
};
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use sum_tree::{self, SeekBias, SumTree};
|
||||
use warpui::text::point::Point;
|
||||
use warpui::text_layout::TextStyle;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
enum Run {
|
||||
Newline,
|
||||
Chars { len: usize, char_size: u8 },
|
||||
}
|
||||
|
||||
impl sum_tree::Item for Run {
|
||||
type Summary = TextSummary;
|
||||
|
||||
fn summary(&self) -> Self::Summary {
|
||||
match *self {
|
||||
Run::Newline => TextSummary {
|
||||
chars: 1.into(),
|
||||
bytes: 1.into(),
|
||||
lines: Point::new(1, 0),
|
||||
first_line_len: 0,
|
||||
rightmost_point: Point::new(0, 0),
|
||||
},
|
||||
Run::Chars { len, char_size } => TextSummary {
|
||||
chars: len.into(),
|
||||
bytes: (len * char_size as usize).into(),
|
||||
lines: Point::new(0, len as u32),
|
||||
first_line_len: len as u32,
|
||||
rightmost_point: Point::new(0, len as u32),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn char_size(&self) -> u8 {
|
||||
match self {
|
||||
Run::Newline => 1,
|
||||
Run::Chars { char_size, .. } => *char_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A summary of text locations.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TextSummary {
|
||||
pub chars: CharOffset,
|
||||
pub bytes: ByteOffset,
|
||||
pub lines: Point,
|
||||
pub first_line_len: u32,
|
||||
pub rightmost_point: Point,
|
||||
}
|
||||
|
||||
impl<'a> std::ops::AddAssign<&'a Self> for TextSummary {
|
||||
fn add_assign(&mut self, other: &'a Self) {
|
||||
let joined_line_len = self.lines.column + other.first_line_len;
|
||||
if joined_line_len > self.rightmost_point.column {
|
||||
self.rightmost_point = Point::new(self.lines.row, joined_line_len);
|
||||
}
|
||||
if other.rightmost_point.column > self.rightmost_point.column {
|
||||
self.rightmost_point = self.lines + other.rightmost_point;
|
||||
}
|
||||
|
||||
if self.lines.row == 0 {
|
||||
self.first_line_len += other.first_line_len;
|
||||
}
|
||||
|
||||
self.chars += other.chars;
|
||||
self.bytes += other.bytes;
|
||||
self.lines += other.lines;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::AddAssign<Self> for TextSummary {
|
||||
fn add_assign(&mut self, other: Self) {
|
||||
*self += &other;
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::Dimension<'_, TextSummary> for TextSummary {
|
||||
fn add_summary(&mut self, summary: &TextSummary) {
|
||||
*self += summary;
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::Dimension<'_, TextSummary> for Point {
|
||||
fn add_summary(&mut self, summary: &TextSummary) {
|
||||
*self += summary.lines;
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::Dimension<'_, TextSummary> for ByteOffset {
|
||||
fn add_summary(&mut self, summary: &TextSummary) {
|
||||
*self += summary.bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::Dimension<'_, TextSummary> for CharOffset {
|
||||
fn add_summary(&mut self, summary: &TextSummary) {
|
||||
*self += summary.chars;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Text {
|
||||
text: Rc<str>,
|
||||
runs: SumTree<Run>,
|
||||
range: Range<CharOffset>,
|
||||
pub text_style: Option<TextStyle>,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn new(text: impl Into<String>, text_style: Option<TextStyle>) -> Self {
|
||||
let mut text = Text::from(text.into());
|
||||
text.text_style = text_style;
|
||||
text
|
||||
}
|
||||
|
||||
pub fn with_text_style(mut self, text_style: impl Into<Option<TextStyle>>) -> Self {
|
||||
self.text_style = text_style.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn fallback_text_style_with<F>(&mut self, fallback: F)
|
||||
where
|
||||
F: FnOnce() -> Option<TextStyle>,
|
||||
{
|
||||
if self.text_style.is_none() {
|
||||
self.text_style = fallback();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_style(&self) -> Option<TextStyle> {
|
||||
self.text_style
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Text {
|
||||
fn from(text: String) -> Self {
|
||||
let mut runs = Vec::new();
|
||||
|
||||
let mut chars_len = 0;
|
||||
let mut run_char_size = 0;
|
||||
let mut run_chars = 0;
|
||||
|
||||
let mut chars = text.chars();
|
||||
loop {
|
||||
let ch = chars.next();
|
||||
let ch_size = ch.map_or(0, |ch| ch.len_utf8());
|
||||
if run_chars != 0 && (ch.is_none() || ch == Some('\n') || run_char_size != ch_size) {
|
||||
runs.push(Run::Chars {
|
||||
len: run_chars,
|
||||
char_size: run_char_size as u8,
|
||||
});
|
||||
run_chars = 0;
|
||||
}
|
||||
run_char_size = ch_size;
|
||||
|
||||
match ch {
|
||||
Some('\n') => runs.push(Run::Newline),
|
||||
Some(_) => run_chars += 1,
|
||||
None => break,
|
||||
}
|
||||
chars_len += 1;
|
||||
}
|
||||
|
||||
let mut tree = SumTree::new();
|
||||
tree.extend(runs);
|
||||
Text {
|
||||
text: text.into(),
|
||||
runs: tree,
|
||||
range: 0.into()..chars_len.into(),
|
||||
text_style: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for Text {
|
||||
fn from(text: &'a str) -> Self {
|
||||
Self::from(String::from(text))
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for Text {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Text")
|
||||
.field("text", &self.text)
|
||||
.field("range", &self.range)
|
||||
.field("text_style", &self.text_style)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Text {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.text == other.text
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Text {}
|
||||
|
||||
impl<T: RangeBounds<CharOffset>> Index<T> for Text {
|
||||
type Output = str;
|
||||
|
||||
fn index(&self, range: T) -> &Self::Output {
|
||||
let start = match range.start_bound() {
|
||||
Bound::Included(start) => cmp::min(self.range.start + *start, self.range.end),
|
||||
Bound::Excluded(_) => unimplemented!(),
|
||||
Bound::Unbounded => self.range.start,
|
||||
};
|
||||
let end = match range.end_bound() {
|
||||
Bound::Included(end) => cmp::min(self.range.start + *end + 1, self.range.end),
|
||||
Bound::Excluded(end) => cmp::min(self.range.start + *end, self.range.end),
|
||||
Bound::Unbounded => self.range.end,
|
||||
};
|
||||
|
||||
let byte_start = self.abs_byte_offset_for_offset(start);
|
||||
let byte_end = self.abs_byte_offset_for_offset(end);
|
||||
&self.text[byte_start.as_usize()..byte_end.as_usize()]
|
||||
}
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn range(&self) -> Range<CharOffset> {
|
||||
self.range.clone()
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self[..]
|
||||
}
|
||||
|
||||
pub fn slice<T: RangeBounds<CharOffset>>(&self, range: T) -> Text {
|
||||
let start = match range.start_bound() {
|
||||
Bound::Included(start) => cmp::min(self.range.start + *start, self.range.end),
|
||||
Bound::Excluded(_) => unimplemented!(),
|
||||
Bound::Unbounded => self.range.start,
|
||||
};
|
||||
let end = match range.end_bound() {
|
||||
Bound::Included(end) => cmp::min(self.range.start + *end + 1, self.range.end),
|
||||
Bound::Excluded(end) => cmp::min(self.range.start + *end, self.range.end),
|
||||
Bound::Unbounded => self.range.end,
|
||||
};
|
||||
|
||||
Text {
|
||||
text: self.text.clone(),
|
||||
runs: self.runs.clone(),
|
||||
range: start..end,
|
||||
text_style: self.text_style,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn line_len(&self, row: u32) -> u32 {
|
||||
let mut cursor = self.runs.cursor::<CharOffset, Point>();
|
||||
cursor.seek(&self.range.start, SeekBias::Right);
|
||||
let absolute_row = cursor.start().row + row;
|
||||
|
||||
let mut cursor = self.runs.cursor::<Point, CharOffset>();
|
||||
cursor.seek(&Point::new(absolute_row, 0), SeekBias::Right);
|
||||
let prefix_len = self.range.start.saturating_sub(cursor.start());
|
||||
let line_len =
|
||||
cursor.summary::<CharOffset>(&Point::new(absolute_row + 1, 0), SeekBias::Left);
|
||||
let suffix_len = cursor.start().saturating_sub(&self.range.end);
|
||||
|
||||
line_len
|
||||
.saturating_sub(&prefix_len)
|
||||
.saturating_sub(&suffix_len)
|
||||
.as_usize() as u32
|
||||
}
|
||||
|
||||
pub fn len(&self) -> CharOffset {
|
||||
self.range.end - self.range.start
|
||||
}
|
||||
|
||||
pub fn byte_len(&self) -> ByteOffset {
|
||||
self.as_str().len().into()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.range.is_empty()
|
||||
}
|
||||
|
||||
pub fn lines(&self) -> Point {
|
||||
self.abs_point_for_offset(self.range.end) - self.abs_point_for_offset(self.range.start)
|
||||
}
|
||||
|
||||
pub fn rightmost_point(&self) -> Point {
|
||||
let lines = self.lines();
|
||||
|
||||
let mut candidates = ArrayVec::<Point, 3>::new();
|
||||
candidates.push(lines);
|
||||
if lines.row > 0 {
|
||||
candidates.push(Point::new(0, self.line_len(0)));
|
||||
if lines.row > 1 {
|
||||
let mut cursor = self.runs.cursor::<CharOffset, Point>();
|
||||
cursor.seek(&self.range.start, SeekBias::Right);
|
||||
let absolute_start_row = cursor.start().row;
|
||||
|
||||
let mut cursor = self.runs.cursor::<Point, CharOffset>();
|
||||
cursor.seek(&Point::new(absolute_start_row + 1, 0), SeekBias::Right);
|
||||
let summary = cursor.summary::<TextSummary>(
|
||||
&Point::new(absolute_start_row + lines.row, 0),
|
||||
SeekBias::Left,
|
||||
);
|
||||
|
||||
candidates.push(Point::new(1, 0) + summary.rightmost_point);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.into_iter().max_by_key(|p| p.column).unwrap()
|
||||
}
|
||||
|
||||
pub fn point_for_offset(&self, offset: CharOffset) -> Point {
|
||||
self.abs_point_for_offset(self.range.start + offset)
|
||||
- self.abs_point_for_offset(self.range.start)
|
||||
}
|
||||
|
||||
pub fn offset_for_point(&self, point: Point) -> CharOffset {
|
||||
let mut cursor = self.runs.cursor::<Point, TextSummary>();
|
||||
let abs_point = self.abs_point_for_offset(self.range.start) + point;
|
||||
cursor.seek(&abs_point, SeekBias::Right);
|
||||
let overshoot = abs_point - cursor.start().lines;
|
||||
let abs_offset = cursor.start().chars.as_usize() + overshoot.column as usize;
|
||||
CharOffset::from(abs_offset) - self.range.start
|
||||
}
|
||||
|
||||
pub fn byte_offset_for_point(&self, point: Point) -> ByteOffset {
|
||||
// Compute the number of characters the `point` is from the start of the text.
|
||||
let character_offset = self.offset_for_point(point);
|
||||
|
||||
let num_bytes_to_point =
|
||||
self.abs_byte_offset_for_offset(character_offset + self.range.start);
|
||||
let num_bytes_to_start = self.abs_byte_offset_for_offset(self.range.start);
|
||||
|
||||
num_bytes_to_point - num_bytes_to_start
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> TextSummary {
|
||||
TextSummary {
|
||||
chars: self.range.end - self.range.start,
|
||||
bytes: self.abs_byte_offset_for_offset(self.range.end)
|
||||
- self.abs_byte_offset_for_offset(self.range.start),
|
||||
lines: self.abs_point_for_offset(self.range.end)
|
||||
- self.abs_point_for_offset(self.range.start),
|
||||
first_line_len: self.line_len(0),
|
||||
rightmost_point: self.rightmost_point(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the number of equivalent chars from the start of the `Text` given the number of
|
||||
/// bytes from the start of the `Text`.
|
||||
pub fn char_offset_for_byte_offset(&self, byte_offset: ByteOffset) -> CharOffset {
|
||||
let mut cursor = self.runs.cursor::<ByteOffset, TextSummary>();
|
||||
let abs_byte_offset = self.abs_byte_offset_for_offset(self.range.start) + byte_offset;
|
||||
cursor.seek(&abs_byte_offset, SeekBias::Right);
|
||||
|
||||
let overshoot = abs_byte_offset - cursor.start().bytes;
|
||||
|
||||
// Determine the number of characters based on the char size of the fragment.
|
||||
let absolute_chars = cursor.start().chars
|
||||
+ ((overshoot).as_usize() / (cursor.item().map_or(1, |run| run.char_size()) as usize));
|
||||
|
||||
// Convert character offset from the start of the text back to a relative offset back to the
|
||||
// the start of the range.
|
||||
absolute_chars - self.range.start
|
||||
}
|
||||
|
||||
fn abs_point_for_offset(&self, offset: CharOffset) -> Point {
|
||||
let mut cursor = self.runs.cursor::<CharOffset, TextSummary>();
|
||||
cursor.seek(&offset, SeekBias::Right);
|
||||
let overshoot = (offset - cursor.start().chars).as_usize() as u32;
|
||||
cursor.start().lines + Point::new(0, overshoot)
|
||||
}
|
||||
|
||||
/// Computes the byte offset from the start of the `self.text` given a character offset from
|
||||
/// the start of `self.text`.
|
||||
fn abs_byte_offset_for_offset(&self, offset: CharOffset) -> ByteOffset {
|
||||
let mut cursor = self.runs.cursor::<CharOffset, TextSummary>();
|
||||
cursor.seek(&offset, SeekBias::Right);
|
||||
let overshoot = (offset - cursor.start().chars).as_usize();
|
||||
cursor.start().bytes + (overshoot * cursor.item().map_or(0, |run| run.char_size()) as usize)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "text_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,155 @@
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::iter::FromIterator;
|
||||
|
||||
#[test]
|
||||
fn test_basic() {
|
||||
let text = Text::from(String::from("ab\ncd€\nfghij\nkl¢m"));
|
||||
assert_eq!(text.len(), 17.into());
|
||||
assert_eq!(text.as_str(), "ab\ncd€\nfghij\nkl¢m");
|
||||
assert_eq!(text.lines(), Point::new(3, 4));
|
||||
assert_eq!(text.line_len(0), 2);
|
||||
assert_eq!(text.line_len(1), 3);
|
||||
assert_eq!(text.line_len(2), 5);
|
||||
assert_eq!(text.line_len(3), 4);
|
||||
assert_eq!(text.rightmost_point(), Point::new(2, 5));
|
||||
|
||||
assert_eq!(text.byte_offset_for_point(Point::new(1, 0)), 3.into());
|
||||
assert_eq!(text.byte_offset_for_point(Point::new(3, 3)), 19.into());
|
||||
|
||||
assert_eq!(text.char_offset_for_byte_offset(3.into()), 3.into());
|
||||
// The string is 20 bytes but only 17 characters.
|
||||
assert_eq!(text.char_offset_for_byte_offset(20.into()), 17.into());
|
||||
|
||||
let b_to_g = text.slice(CharOffset::from(1)..CharOffset::from(9));
|
||||
assert_eq!(b_to_g.as_str(), "b\ncd€\nfg");
|
||||
assert_eq!(b_to_g.len(), 8.into());
|
||||
assert_eq!(b_to_g.lines(), Point::new(2, 2));
|
||||
assert_eq!(b_to_g.line_len(0), 1);
|
||||
assert_eq!(b_to_g.line_len(1), 3);
|
||||
assert_eq!(b_to_g.line_len(2), 2);
|
||||
assert_eq!(b_to_g.line_len(3), 0);
|
||||
assert_eq!(b_to_g.rightmost_point(), Point::new(1, 3));
|
||||
|
||||
assert_eq!(b_to_g.byte_offset_for_point(Point::new(1, 0)), 2.into());
|
||||
assert_eq!(b_to_g.byte_offset_for_point(Point::new(2, 1)), 9.into());
|
||||
|
||||
assert_eq!(b_to_g.char_offset_for_byte_offset(6.into()), 4.into());
|
||||
// The string is 10 bytes but only 8 characters.
|
||||
assert_eq!(b_to_g.char_offset_for_byte_offset(9.into()), 7.into());
|
||||
|
||||
let d_to_i = text.slice(CharOffset::from(4)..CharOffset::from(11));
|
||||
assert_eq!(d_to_i.as_str(), "d€\nfghi");
|
||||
assert_eq!(&d_to_i[CharOffset::from(1)..CharOffset::from(5)], "€\nfg");
|
||||
assert_eq!(d_to_i.len(), 7.into());
|
||||
assert_eq!(d_to_i.lines(), Point::new(1, 4));
|
||||
assert_eq!(d_to_i.line_len(0), 2);
|
||||
assert_eq!(d_to_i.line_len(1), 4);
|
||||
assert_eq!(d_to_i.line_len(2), 0);
|
||||
assert_eq!(d_to_i.rightmost_point(), Point::new(1, 4));
|
||||
|
||||
assert_eq!(d_to_i.byte_offset_for_point(Point::new(1, 0)), 5.into());
|
||||
assert_eq!(d_to_i.byte_offset_for_point(Point::new(1, 3)), 8.into());
|
||||
|
||||
// A byte index in the middle of a character should return the character before.
|
||||
assert_eq!(d_to_i.char_offset_for_byte_offset(1.into()), 1.into());
|
||||
assert_eq!(d_to_i.char_offset_for_byte_offset(2.into()), 1.into());
|
||||
|
||||
// The string is 8 bytes but only 7 characters.
|
||||
assert_eq!(d_to_i.char_offset_for_byte_offset(9.into()), 7.into());
|
||||
|
||||
let d_to_j = text.slice(CharOffset::from(4)..=CharOffset::from(11));
|
||||
assert_eq!(d_to_j.as_str(), "d€\nfghij");
|
||||
assert_eq!(&d_to_j[CharOffset::from(1)..], "€\nfghij");
|
||||
assert_eq!(d_to_j.len(), 8.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random() {
|
||||
use rand::prelude::*;
|
||||
|
||||
for seed in 0..100 {
|
||||
println!("buffer::text seed: {seed}");
|
||||
let rng = &mut StdRng::seed_from_u64(seed);
|
||||
|
||||
let len: i32 = rng.gen_range(0..50);
|
||||
let mut string = String::new();
|
||||
for _ in 0..len {
|
||||
if rng.gen_ratio(1, 5) {
|
||||
string.push('\n');
|
||||
} else {
|
||||
string.push(rng.gen());
|
||||
}
|
||||
}
|
||||
let text = Text::from(string.clone());
|
||||
|
||||
for _ in 0..10 {
|
||||
let start = CharOffset::from(rng.gen_range(0..text.len().as_usize() + 1));
|
||||
let end = CharOffset::from(rng.gen_range(start.as_usize()..text.len().as_usize() + 2));
|
||||
|
||||
let string_slice = string
|
||||
.chars()
|
||||
.skip(start.as_usize())
|
||||
.take(end.as_usize() - start.as_usize())
|
||||
.collect::<String>();
|
||||
let expected_line_endpoints = string_slice
|
||||
.split('\n')
|
||||
.enumerate()
|
||||
.map(|(row, line)| Point::new(row as u32, line.chars().count() as u32))
|
||||
.collect::<Vec<_>>();
|
||||
let text_slice = text.slice(start..end);
|
||||
|
||||
assert_eq!(text_slice.lines(), lines(&string_slice));
|
||||
|
||||
let mut rightmost_points: HashSet<Point> = HashSet::new();
|
||||
for endpoint in &expected_line_endpoints {
|
||||
if let Some(rightmost_point) = rightmost_points.iter().next().cloned() {
|
||||
if endpoint.column > rightmost_point.column {
|
||||
rightmost_points.clear();
|
||||
}
|
||||
if endpoint.column >= rightmost_point.column {
|
||||
rightmost_points.insert(*endpoint);
|
||||
}
|
||||
} else {
|
||||
rightmost_points.insert(*endpoint);
|
||||
}
|
||||
|
||||
assert_eq!(text_slice.line_len(endpoint.row), endpoint.column);
|
||||
}
|
||||
|
||||
assert!(rightmost_points.contains(&text_slice.rightmost_point()));
|
||||
|
||||
for _ in 0..10 {
|
||||
let offset = CharOffset::from(rng.gen_range(0..string_slice.chars().count() + 1));
|
||||
let point = lines(
|
||||
&string_slice
|
||||
.chars()
|
||||
.take(offset.as_usize())
|
||||
.collect::<String>(),
|
||||
);
|
||||
assert_eq!(text_slice.point_for_offset(offset), point);
|
||||
assert_eq!(text_slice.offset_for_point(point), offset);
|
||||
if offset < string_slice.chars().count().into() {
|
||||
assert_eq!(
|
||||
&text_slice[offset..offset + 1],
|
||||
String::from_iter(string_slice.chars().nth(offset.as_usize())).as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lines(s: &str) -> Point {
|
||||
let mut row = 0;
|
||||
let mut column = 0;
|
||||
for ch in s.chars() {
|
||||
if ch == '\n' {
|
||||
row += 1;
|
||||
column = 0;
|
||||
} else {
|
||||
column += 1;
|
||||
}
|
||||
}
|
||||
Point::new(row, column)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::{self, Ordering};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use uuid::Uuid;
|
||||
|
||||
const BASE_REPLICA_ID: &str = "0";
|
||||
|
||||
/// A unique ID assigned to every peer in the system.
|
||||
#[derive(Clone, Hash, Eq, PartialEq, Debug, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
pub struct ReplicaId(Rc<String>);
|
||||
|
||||
impl ReplicaId {
|
||||
pub fn new(id: impl ToString) -> Self {
|
||||
let id = Self(Rc::new(id.to_string()));
|
||||
debug_assert!(id.0.as_str() != BASE_REPLICA_ID);
|
||||
id
|
||||
}
|
||||
|
||||
/// Creates a sufficiently random id.
|
||||
pub fn random() -> Self {
|
||||
Self::new(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// A sentinel replica ID for the base text.
|
||||
/// The base text insertion is considered to be a replica-less edit.
|
||||
pub fn base_replica_id() -> Self {
|
||||
Self(Rc::new(BASE_REPLICA_ID.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ReplicaId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A bare [lamport timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp).
|
||||
/// Prefer to use the full [`Lamport`] type unless the replica ID is known / fixed.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct LamportValue(usize);
|
||||
|
||||
impl LamportValue {
|
||||
fn next(&self) -> Self {
|
||||
LamportValue(self.0 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for LamportValue {
|
||||
fn from(val: usize) -> Self {
|
||||
LamportValue(val)
|
||||
}
|
||||
}
|
||||
|
||||
/// A [lamport timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp).
|
||||
///
|
||||
/// Along with the bare [`LamportValue`], we also store the replica ID to identify
|
||||
/// the origin of the event associated to this timestamp. This allows us to achieve
|
||||
/// a total ordering of events (see https://en.wikipedia.org/wiki/Lamport_timestamp#Implications).
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Lamport {
|
||||
pub replica_id: ReplicaId,
|
||||
pub value: LamportValue,
|
||||
}
|
||||
|
||||
impl Lamport {
|
||||
pub fn new(replica_id: ReplicaId) -> Self {
|
||||
Self {
|
||||
value: LamportValue::default(),
|
||||
replica_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) -> Self {
|
||||
let timestamp = self.clone();
|
||||
self.value.0 += 1;
|
||||
timestamp
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, timestamp: &Self) {
|
||||
self.value = cmp::max(self.value, timestamp.value).next();
|
||||
}
|
||||
|
||||
pub fn replica_id(&self) -> ReplicaId {
|
||||
self.replica_id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Lamport {
|
||||
/// When comparing lamport timestamps, we break ties using the replica ID
|
||||
/// to get a total ordering.
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
(self.value, &self.replica_id).cmp(&(other.value, &other.replica_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Lamport {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// A [version vector](https://en.wikipedia.org/wiki/Version_vector) to track
|
||||
/// the latest lamport timestamp seen for every peer in the system.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Global(Rc<HashMap<ReplicaId, LamportValue>>);
|
||||
|
||||
impl Default for Global {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Global {
|
||||
pub fn new() -> Self {
|
||||
Global(Rc::new(HashMap::new()))
|
||||
}
|
||||
|
||||
pub fn get(&self, replica_id: &ReplicaId) -> LamportValue {
|
||||
self.0.get(replica_id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, timestamp: &Lamport) {
|
||||
let map = Rc::make_mut(&mut self.0);
|
||||
let value = map.entry(timestamp.replica_id.clone()).or_default();
|
||||
*value = cmp::max(*value, timestamp.value);
|
||||
}
|
||||
|
||||
pub fn observe_all(&mut self, other: &Self) {
|
||||
for (replica_id, value) in other.0.as_ref() {
|
||||
self.observe(&Lamport {
|
||||
replica_id: replica_id.clone(),
|
||||
value: *value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observed(&self, timestamp: &Lamport) -> bool {
|
||||
self.get(×tamp.replica_id) >= timestamp.value
|
||||
}
|
||||
|
||||
pub fn changed_since(&self, other: &Self) -> bool {
|
||||
self.0
|
||||
.iter()
|
||||
.any(|(replica_id, value)| *value > other.get(replica_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Global {
|
||||
/// Returns Some(Ordering) iff the ordering is conclusive.
|
||||
/// Otherwise, the version vectors being compared are
|
||||
/// concurrent and so the ordering is undefined ([`None`]).
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
let mut global_ordering = Ordering::Equal;
|
||||
|
||||
for replica_id in self.0.keys().chain(other.0.keys()) {
|
||||
let ordering = self.get(replica_id).cmp(&other.get(replica_id));
|
||||
if ordering != Ordering::Equal {
|
||||
if global_ordering == Ordering::Equal {
|
||||
global_ordering = ordering;
|
||||
} else if ordering != global_ordering {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(global_ordering)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use bounded_vec_deque::BoundedVecDeque;
|
||||
use instant::Instant;
|
||||
|
||||
use super::time::{Global, Lamport, LamportValue};
|
||||
use crate::editor::view::{model::LocalSelections, PlainTextEditorViewAction};
|
||||
|
||||
/// The maximum time we will batch consecutive edits for the same [`Action`].
|
||||
/// The "batch" here is not to be confused with the [`Buffer`]'s notion
|
||||
/// of a "batch". A single undo / redo batched item might consist
|
||||
/// of multiple buffer batched items.
|
||||
const UNDO_REDO_BATCH_TIMER: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The maximum size of the undo stack.
|
||||
/// TODO: this could be substantially larger now that we don't store snapshots.
|
||||
const DEFAULT_UNDO_STACK_CAPACITY: usize = 20;
|
||||
|
||||
/// An entry in the [`LocalUndoStack`].
|
||||
#[derive(Clone)]
|
||||
struct UndoStackEntry {
|
||||
/// The set of operations that would be undone.
|
||||
/// These numbers are lamport timestamps corresponding to the edit operations.
|
||||
/// We don't need a full [`Lamport`] timestamp because the replica ID would be
|
||||
/// redundant (this is only for local edits).
|
||||
ops: Vec<LamportValue>,
|
||||
|
||||
/// A snapshot of the selection state after the ops.
|
||||
selections: LocalSelections,
|
||||
|
||||
/// The action associated with this entry.
|
||||
/// If this is the current item on the stack and the last
|
||||
/// change in the current batch was a pure selection change, this will be
|
||||
/// [`Action::CursorChanged`].
|
||||
action: PlainTextEditorViewAction,
|
||||
}
|
||||
|
||||
/// A stack of batched, local operations that can be undone / redone.
|
||||
/// The undo stack batches consecutive records within a [`UNDO_REDO_BATCH_TIMER`]
|
||||
/// period if:
|
||||
/// - the records are associated to the same action
|
||||
/// - the associated action is not atomic
|
||||
/// - there aren't any undos (we're at the top of the stack)
|
||||
#[derive(Clone)]
|
||||
pub struct LocalUndoStack {
|
||||
/// The stack itself.
|
||||
stack: BoundedVecDeque<UndoStackEntry>,
|
||||
|
||||
/// Where we are in the stack currently.
|
||||
/// As we undo / redo, the position into the stack
|
||||
/// changes but the stack itself stays in tact.
|
||||
current_index: usize,
|
||||
|
||||
/// The absolute time when the stack was last modified.
|
||||
last_changed_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl LocalUndoStack {
|
||||
pub fn new(init_selections: LocalSelections) -> LocalUndoStack {
|
||||
Self::new_with_capacity(init_selections, DEFAULT_UNDO_STACK_CAPACITY)
|
||||
}
|
||||
|
||||
fn new_with_capacity(init_selections: LocalSelections, capacity: usize) -> LocalUndoStack {
|
||||
let init_entry = UndoStackEntry {
|
||||
ops: vec![],
|
||||
selections: init_selections,
|
||||
action: PlainTextEditorViewAction::ReplaceBuffer,
|
||||
};
|
||||
Self {
|
||||
stack: BoundedVecDeque::from_iter([init_entry], capacity),
|
||||
current_index: 0,
|
||||
last_changed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a selection change by updating the current item's selection snapshot.
|
||||
pub fn record_selection_change(&mut self, selections: LocalSelections) {
|
||||
self.stack[self.current_index].selections = selections;
|
||||
self.stack[self.current_index].action = PlainTextEditorViewAction::CursorChanged;
|
||||
}
|
||||
|
||||
/// Records an edit and potentially batches it with other edits if possible.
|
||||
pub fn record_edit(
|
||||
&mut self,
|
||||
action: PlainTextEditorViewAction,
|
||||
operations: impl Iterator<Item = LamportValue>,
|
||||
selections: LocalSelections,
|
||||
) {
|
||||
let should_insert_new_entry = self.current_index == 0
|
||||
|| self.current_index < self.stack.len() - 1
|
||||
|| action.is_atomic()
|
||||
|| Some(action) != self.last_action()
|
||||
|| self
|
||||
.last_changed_at
|
||||
.is_none_or(|t| t.elapsed() >= UNDO_REDO_BATCH_TIMER);
|
||||
|
||||
// If the index is positioned somewhere other than the last item,
|
||||
// this means that the user is making an edit after some undo'ing.
|
||||
// Any of the items past the current index are not needed anymore.
|
||||
self.stack.truncate(self.current_index + 1);
|
||||
|
||||
if should_insert_new_entry {
|
||||
// Since we're using a [`BoundedVecDeque`], pushing to the
|
||||
// back might remove from the front if the stack is at capacity.
|
||||
self.stack.push_back(UndoStackEntry {
|
||||
action,
|
||||
ops: operations.collect(),
|
||||
selections,
|
||||
});
|
||||
self.current_index = self.stack.len() - 1;
|
||||
} else {
|
||||
let back = self.stack.back_mut().unwrap();
|
||||
back.ops.extend(operations);
|
||||
back.selections = selections;
|
||||
}
|
||||
|
||||
self.last_changed_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Processes an undo and returns
|
||||
/// 1. the set of operations that need to be undone
|
||||
/// 2. the set of selections that need to be restored
|
||||
pub fn undo(&mut self) -> Option<(Vec<LamportValue>, LocalSelections)> {
|
||||
if self.current_index == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.last_changed_at = None;
|
||||
|
||||
let ops = self.stack[self.current_index].ops.clone();
|
||||
self.current_index -= 1;
|
||||
let selections = self.stack[self.current_index].selections.clone();
|
||||
|
||||
Some((ops, selections))
|
||||
}
|
||||
|
||||
/// Processes a redo and returns
|
||||
/// 1. the set of operations that need to be redone
|
||||
/// 2. the set of selections that need to be restored
|
||||
pub fn redo(&mut self) -> Option<(Vec<LamportValue>, LocalSelections)> {
|
||||
if self.current_index == self.stack.len() - 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.last_changed_at = None;
|
||||
|
||||
self.current_index += 1;
|
||||
let UndoStackEntry {
|
||||
ops, selections, ..
|
||||
} = self.stack[self.current_index].clone();
|
||||
|
||||
Some((ops, selections))
|
||||
}
|
||||
|
||||
/// Resets the undo stack to a single item with the given selections.
|
||||
pub fn reset(&mut self, selections: LocalSelections) {
|
||||
let init_entry = UndoStackEntry {
|
||||
ops: vec![],
|
||||
selections,
|
||||
action: PlainTextEditorViewAction::ReplaceBuffer,
|
||||
};
|
||||
self.stack = BoundedVecDeque::from_iter([init_entry], DEFAULT_UNDO_STACK_CAPACITY);
|
||||
self.last_changed_at = None;
|
||||
self.current_index = 0;
|
||||
}
|
||||
|
||||
/// Returns the action associated to the last item on the undo stack.
|
||||
pub fn last_action(&self) -> Option<PlainTextEditorViewAction> {
|
||||
if self.stack.len() == 1 {
|
||||
return None;
|
||||
};
|
||||
self.stack.back().map(|e| e.action)
|
||||
}
|
||||
}
|
||||
|
||||
/// A history of local and remote undos.
|
||||
#[derive(Clone)]
|
||||
pub struct UndoHistory {
|
||||
/// A mapping from an edit ID to the lamport timestamps of undo operations for that edit.
|
||||
/// The values are bare lamport times because the replica ID of an undo operation will
|
||||
/// always be the replica ID for the edit itself (one can only undo their own edits).
|
||||
///
|
||||
/// Suppose an undo operation of an edit `e` is defined as `undo(e)`.
|
||||
/// Then, a redo of an edit operation `e` can be thought of as `undo(undo(e))`.
|
||||
/// Using that line of logic, an edit is considered undone iff the number of undos are odd.
|
||||
///
|
||||
/// We use a [`BTreeSet`] to quickly query the max undo ts for a given edit
|
||||
/// and to efficiently compute intersections with other sets (e.g. deletions).
|
||||
map: HashMap<Lamport, BTreeSet<LamportValue>>,
|
||||
}
|
||||
|
||||
impl UndoHistory {
|
||||
pub fn new() -> Self {
|
||||
UndoHistory {
|
||||
map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the largest known undo timestamp for `edit`.
|
||||
fn max_undo_ts(&self, edit_id: &Lamport) -> LamportValue {
|
||||
self.map
|
||||
.get(edit_id)
|
||||
.and_then(|v| v.last().copied())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Registers an undo for `edit`
|
||||
pub fn undo(&mut self, edit_id: Lamport, undo_ts: LamportValue) {
|
||||
// The undo timestamp should be larger than all undo timestamps
|
||||
// so far for this edit and must be after the edit itself.
|
||||
debug_assert!(undo_ts > self.max_undo_ts(&edit_id) && undo_ts > edit_id.value);
|
||||
self.map.entry(edit_id).or_default().insert(undo_ts);
|
||||
}
|
||||
|
||||
/// Returns true iff `edit` is considered undone.
|
||||
/// An edit is considered undone iff it has an odd number of undos.
|
||||
/// In other words, there exists some undo that wasn't redone.
|
||||
pub fn is_edit_undone(&self, edit_id: &Lamport) -> bool {
|
||||
self.map.get(edit_id).map_or(0, |v| v.len()) % 2 == 1
|
||||
}
|
||||
|
||||
/// Returns true iff `edit` was undone when the version vector was `version`.
|
||||
pub fn was_edit_undone(&self, edit_id: &Lamport, version: &Global) -> bool {
|
||||
let num_undos = self
|
||||
.map
|
||||
.get(edit_id)
|
||||
.iter()
|
||||
.flat_map(|v| v.iter())
|
||||
.take_while(|undo| {
|
||||
version.observed(&Lamport {
|
||||
replica_id: edit_id.replica_id(),
|
||||
value: **undo,
|
||||
})
|
||||
})
|
||||
.count();
|
||||
num_undos % 2 == 1
|
||||
}
|
||||
}
|
||||
|
||||
impl PlainTextEditorViewAction {
|
||||
/// Whether the action is atomic. An atomic action is one where a single event
|
||||
/// is independently undoable. This differs from non-atomic actions (such as `INSERT_TEXT`)
|
||||
/// where multiple events are coalesced and undone together if they occur within [`UNDO_REDO_BATCH_TIMER`] of each other.
|
||||
pub fn is_atomic(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
PlainTextEditorViewAction::Yank
|
||||
| PlainTextEditorViewAction::AcceptCompletionSuggestion
|
||||
| PlainTextEditorViewAction::NewLine
|
||||
| PlainTextEditorViewAction::DeleteWordLeft
|
||||
| PlainTextEditorViewAction::DeleteWordRight
|
||||
| PlainTextEditorViewAction::AutoSuggestion
|
||||
| PlainTextEditorViewAction::ReplaceBuffer
|
||||
| PlainTextEditorViewAction::Paste
|
||||
| PlainTextEditorViewAction::ClearLines
|
||||
| PlainTextEditorViewAction::ClearAndCopyLines
|
||||
| PlainTextEditorViewAction::DeleteAll
|
||||
| PlainTextEditorViewAction::CutAll
|
||||
| PlainTextEditorViewAction::InsertSelectedText
|
||||
| PlainTextEditorViewAction::CutWordRight
|
||||
| PlainTextEditorViewAction::SystemInsert
|
||||
| PlainTextEditorViewAction::ExpandAlias
|
||||
| PlainTextEditorViewAction::CycleCompletionSuggestion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "undo_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,305 @@
|
||||
use super::{LocalUndoStack, UndoHistory};
|
||||
use crate::editor::{
|
||||
view::model::{
|
||||
buffer::{
|
||||
time::{Global, Lamport, LamportValue},
|
||||
undo::UNDO_REDO_BATCH_TIMER,
|
||||
ReplicaId,
|
||||
},
|
||||
Anchor, LocalSelection, LocalSelections,
|
||||
},
|
||||
PlainTextEditorViewAction,
|
||||
};
|
||||
use vec1::vec1;
|
||||
|
||||
fn local_selections(start: Anchor, end: Anchor) -> LocalSelections {
|
||||
LocalSelections {
|
||||
pending: None,
|
||||
selections: vec1![LocalSelection::new_for_test(start, end)],
|
||||
marked_text_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn lamport(value: impl Into<LamportValue>) -> Lamport {
|
||||
Lamport {
|
||||
replica_id: ReplicaId::new(1),
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_stack_initialization() {
|
||||
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
|
||||
assert!(undo_stack.undo().is_none());
|
||||
assert!(undo_stack.redo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_stack_undo_redo() {
|
||||
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
|
||||
|
||||
// [`Action::ReplaceBuffer`] is an atomic action so these edits won't be coalesced.
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[0.into()].into_iter(),
|
||||
local_selections(Anchor::Start, Anchor::End),
|
||||
);
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[1.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::Start),
|
||||
);
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[2.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::End),
|
||||
);
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, [2.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, [1.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, [0.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::Start));
|
||||
|
||||
assert!(undo_stack.undo().is_none());
|
||||
|
||||
let (ops, selections) = undo_stack.redo().unwrap();
|
||||
assert_eq!(ops, [0.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
|
||||
|
||||
let (ops, selections) = undo_stack.redo().unwrap();
|
||||
assert_eq!(ops, [1.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
|
||||
|
||||
let (ops, selections) = undo_stack.redo().unwrap();
|
||||
assert_eq!(ops, [2.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::End, Anchor::End));
|
||||
|
||||
assert!(undo_stack.redo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_stack_selections_changed() {
|
||||
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
|
||||
|
||||
undo_stack.record_selection_change(local_selections(Anchor::Start, Anchor::End));
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[0.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::End),
|
||||
);
|
||||
undo_stack.record_selection_change(local_selections(Anchor::End, Anchor::Start));
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![0.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
|
||||
|
||||
let (ops, selections) = undo_stack.redo().unwrap();
|
||||
assert_eq!(ops, vec![0.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_stack_coalescing() {
|
||||
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
|
||||
|
||||
// [`Action::InsertChar`] is not an atomic action so these edits should be coalesced.
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::InsertChar,
|
||||
[0.into()].into_iter(),
|
||||
local_selections(Anchor::Start, Anchor::End),
|
||||
);
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::InsertChar,
|
||||
[1.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::Start),
|
||||
);
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::InsertChar,
|
||||
[2.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::End),
|
||||
);
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![0.into(), 1.into(), 2.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::Start));
|
||||
|
||||
assert!(undo_stack.undo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_stack_expired_timer() {
|
||||
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
|
||||
|
||||
// [`Action::InsertChar`] is not an atomic action so these edits should normally be coalesced
|
||||
// but can't be in this case because the timer expires.
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::InsertChar,
|
||||
[0.into()].into_iter(),
|
||||
local_selections(Anchor::Start, Anchor::End),
|
||||
);
|
||||
std::thread::sleep(UNDO_REDO_BATCH_TIMER + std::time::Duration::from_millis(10));
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::InsertChar,
|
||||
[1.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::Start),
|
||||
);
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![1.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![0.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::Start));
|
||||
|
||||
assert!(undo_stack.undo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_stack_undo_fork() {
|
||||
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
|
||||
|
||||
// [`Action::ReplaceBuffer`] is an atomic action so these edits won't be coalesced.
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[0.into()].into_iter(),
|
||||
local_selections(Anchor::Start, Anchor::End),
|
||||
);
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[1.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::Start),
|
||||
);
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[2.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::End),
|
||||
);
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![2.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![1.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
|
||||
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[4.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::End),
|
||||
);
|
||||
|
||||
assert!(undo_stack.redo().is_none());
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![4.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
|
||||
|
||||
let (ops, selections) = undo_stack.redo().unwrap();
|
||||
assert_eq!(ops, vec![4.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::End, Anchor::End));
|
||||
|
||||
assert!(undo_stack.redo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_stack_capacity() {
|
||||
let mut undo_stack =
|
||||
LocalUndoStack::new_with_capacity(local_selections(Anchor::Start, Anchor::Start), 3);
|
||||
|
||||
// [`Action::ReplaceBuffer`] is an atomic action so these edits won't be coalesced.
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[0.into()].into_iter(),
|
||||
local_selections(Anchor::Start, Anchor::End),
|
||||
);
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[1.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::Start),
|
||||
);
|
||||
undo_stack.record_edit(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
[2.into()].into_iter(),
|
||||
local_selections(Anchor::End, Anchor::End),
|
||||
);
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![2.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
|
||||
|
||||
let (ops, selections) = undo_stack.undo().unwrap();
|
||||
assert_eq!(ops, vec![1.into()]);
|
||||
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
|
||||
|
||||
// Since the capacity of the stack is 3, it can only keep track of the latest
|
||||
// 2 edits (because it also tracks the base entry).
|
||||
assert!(undo_stack.undo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_undo_ts() {
|
||||
let mut undo_history = UndoHistory::new();
|
||||
|
||||
undo_history.undo(lamport(1), 2.into());
|
||||
undo_history.undo(lamport(0), 3.into());
|
||||
undo_history.undo(lamport(1), 5.into());
|
||||
undo_history.undo(lamport(0), 7.into());
|
||||
|
||||
assert_eq!(undo_history.max_undo_ts(&lamport(0)), 7.into());
|
||||
assert_eq!(undo_history.max_undo_ts(&lamport(1)), 5.into());
|
||||
assert_eq!(undo_history.max_undo_ts(&lamport(2)), 0.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_undo_history_grow_only() {
|
||||
let mut undo_history = UndoHistory::new();
|
||||
undo_history.undo(lamport(1), 3.into());
|
||||
undo_history.undo(lamport(1), 2.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_edit_undone() {
|
||||
let mut undo_history = UndoHistory::new();
|
||||
|
||||
undo_history.undo(lamport(1), 2.into());
|
||||
undo_history.undo(lamport(0), 3.into());
|
||||
undo_history.undo(lamport(1), 5.into());
|
||||
|
||||
assert!(undo_history.is_edit_undone(&lamport(0)));
|
||||
assert!(!undo_history.is_edit_undone(&lamport(1)));
|
||||
assert!(!undo_history.is_edit_undone(&lamport(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_was_edit_undone() {
|
||||
let mut undo_history = UndoHistory::new();
|
||||
let mut versions = Global::new();
|
||||
|
||||
undo_history.undo(lamport(1), 2.into());
|
||||
undo_history.undo(lamport(1), 3.into());
|
||||
undo_history.undo(lamport(1), 5.into());
|
||||
|
||||
assert!(!undo_history.was_edit_undone(&lamport(1), &versions));
|
||||
|
||||
versions.observe(&lamport(2));
|
||||
assert!(undo_history.was_edit_undone(&lamport(1), &versions));
|
||||
|
||||
versions.observe(&lamport(3));
|
||||
assert!(!undo_history.was_edit_undone(&lamport(1), &versions));
|
||||
|
||||
versions.observe(&lamport(4));
|
||||
assert!(!undo_history.was_edit_undone(&lamport(1), &versions));
|
||||
|
||||
versions.observe(&lamport(5));
|
||||
assert!(undo_history.was_edit_undone(&lamport(1), &versions));
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
use super::super::buffer::{AnchorRangeExt, TextSummary};
|
||||
use super::buffer::StylizedChar;
|
||||
use super::{buffer, Anchor, Buffer, DisplayPoint, Edit, Point, ToCharOffset};
|
||||
use crate::util::extensions::SliceExt as _;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::{
|
||||
cmp::{self, Ordering},
|
||||
iter::Take,
|
||||
ops::Range,
|
||||
};
|
||||
use string_offset::CharOffset;
|
||||
use sum_tree::{self, Cursor, Dimension, SeekBias, SumTree};
|
||||
use warpui::text_layout::TextStyle;
|
||||
use warpui::{AppContext, ModelHandle};
|
||||
|
||||
pub struct FoldMap {
|
||||
buffer: ModelHandle<Buffer>,
|
||||
transforms: SumTree<Transform>,
|
||||
folds: Vec<Range<Anchor>>,
|
||||
}
|
||||
|
||||
impl FoldMap {
|
||||
pub fn new(buffer: ModelHandle<Buffer>, app: &AppContext) -> Self {
|
||||
let text_summary = buffer.as_ref(app).text_summary();
|
||||
Self {
|
||||
buffer,
|
||||
folds: Vec::new(),
|
||||
transforms: SumTree::from_item(Transform {
|
||||
summary: TransformSummary {
|
||||
buffer: text_summary.clone(),
|
||||
display: text_summary,
|
||||
},
|
||||
display_text: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn buffer_rows(&self, start_row: u32) -> Result<BufferRows<'_>> {
|
||||
if start_row > self.transforms.summary().display.lines.row {
|
||||
return Err(anyhow!("invalid display row {}", start_row));
|
||||
}
|
||||
|
||||
let display_point = Point::new(start_row, 0);
|
||||
let mut cursor = self.transforms.cursor();
|
||||
cursor.seek(&DisplayPoint(display_point), SeekBias::Left);
|
||||
|
||||
Ok(BufferRows {
|
||||
cursor,
|
||||
display_point,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> CharOffset {
|
||||
self.transforms.summary().display.chars
|
||||
}
|
||||
|
||||
pub fn line_len(&self, row: u32, ctx: &AppContext) -> Result<u32> {
|
||||
let line_start = self.to_display_offset(DisplayPoint::new(row, 0), ctx)?.0;
|
||||
let line_end = if row >= self.max_point().row() {
|
||||
self.len().as_usize()
|
||||
} else {
|
||||
self.to_display_offset(DisplayPoint::new(row + 1, 0), ctx)?
|
||||
.0
|
||||
- 1
|
||||
};
|
||||
|
||||
Ok((line_end - line_start) as u32)
|
||||
}
|
||||
|
||||
pub fn chars_with_style_at<'a>(
|
||||
&'a self,
|
||||
point: DisplayPoint,
|
||||
app: &'a AppContext,
|
||||
) -> Result<CharsWithStyle<'a>> {
|
||||
let offset = self.to_display_offset(point, app)?;
|
||||
let mut cursor = self.transforms.cursor();
|
||||
cursor.seek(&offset, SeekBias::Right);
|
||||
let buffer = self.buffer.as_ref(app);
|
||||
Ok(CharsWithStyle {
|
||||
cursor,
|
||||
offset: CharOffset::from(offset.0),
|
||||
buffer,
|
||||
buffer_chars: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn chars_at<'a>(&'a self, point: DisplayPoint, app: &'a AppContext) -> Result<Chars<'a>> {
|
||||
Ok(Chars(self.chars_with_style_at(point, app)?))
|
||||
}
|
||||
|
||||
pub fn max_point(&self) -> DisplayPoint {
|
||||
DisplayPoint(self.transforms.summary().display.lines)
|
||||
}
|
||||
|
||||
pub fn rightmost_point(&self) -> DisplayPoint {
|
||||
DisplayPoint(self.transforms.summary().display.rightmost_point)
|
||||
}
|
||||
|
||||
pub fn fold<T: ToCharOffset>(
|
||||
&mut self,
|
||||
ranges: impl IntoIterator<Item = Range<T>>,
|
||||
app: &AppContext,
|
||||
) -> Result<()> {
|
||||
let mut edits = Vec::new();
|
||||
let buffer = self.buffer.as_ref(app);
|
||||
for range in ranges.into_iter() {
|
||||
let start = range.start.to_char_offset(buffer)?;
|
||||
let end = range.end.to_char_offset(buffer)?;
|
||||
edits.push(Edit {
|
||||
old_range: start..end,
|
||||
new_range: start..end,
|
||||
});
|
||||
|
||||
let fold = buffer.anchor_after(start)?..buffer.anchor_before(end)?;
|
||||
let ix = self
|
||||
.folds
|
||||
.find_insertion_index(|probe| probe.cmp(&fold, buffer))?;
|
||||
self.folds.insert(ix, fold);
|
||||
}
|
||||
edits.sort_unstable_by(|a, b| {
|
||||
a.old_range
|
||||
.start
|
||||
.cmp(&b.old_range.start)
|
||||
.then_with(|| b.old_range.end.cmp(&a.old_range.end))
|
||||
});
|
||||
|
||||
self.apply_edits(&edits, app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unfold<T: ToCharOffset>(
|
||||
&mut self,
|
||||
ranges: impl IntoIterator<Item = Range<T>>,
|
||||
app: &AppContext,
|
||||
) -> Result<()> {
|
||||
let buffer = self.buffer.as_ref(app);
|
||||
|
||||
let mut edits = Vec::new();
|
||||
for range in ranges.into_iter() {
|
||||
let start = buffer.anchor_before(range.start.to_char_offset(buffer)?)?;
|
||||
let end = buffer.anchor_after(range.end.to_char_offset(buffer)?)?;
|
||||
|
||||
// Remove intersecting folds and add their ranges to edits that are passed to apply_edits
|
||||
self.folds.retain(|fold| {
|
||||
if fold.start.cmp(&end, buffer).unwrap() > Ordering::Equal
|
||||
|| fold.end.cmp(&start, buffer).unwrap() < Ordering::Equal
|
||||
{
|
||||
true
|
||||
} else {
|
||||
let start = fold.start.to_char_offset(buffer);
|
||||
let end = fold.end.to_char_offset(buffer);
|
||||
if let Ok((start, end)) = start.and_then(|start| Ok((start, end?))) {
|
||||
let offset_range = start..end;
|
||||
edits.push(Edit {
|
||||
old_range: offset_range.clone(),
|
||||
new_range: offset_range,
|
||||
});
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.apply_edits(&edits, app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_line_folded(&self, display_row: u32) -> bool {
|
||||
let mut cursor = self.transforms.cursor::<DisplayPoint, DisplayPoint>();
|
||||
cursor.seek(&DisplayPoint::new(display_row, 0), SeekBias::Right);
|
||||
while let Some(transform) = cursor.item() {
|
||||
if transform.display_text.is_some() {
|
||||
return true;
|
||||
}
|
||||
if cursor.end().row() == display_row {
|
||||
cursor.next()
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn to_display_offset(
|
||||
&self,
|
||||
point: DisplayPoint,
|
||||
app: &AppContext,
|
||||
) -> Result<DisplayOffset> {
|
||||
let mut cursor = self.transforms.cursor::<DisplayPoint, TransformSummary>();
|
||||
cursor.seek(&point, SeekBias::Right);
|
||||
let overshoot = point.0 - cursor.start().display.lines;
|
||||
let mut offset = cursor.start().display.chars;
|
||||
if !overshoot.is_zero() {
|
||||
let transform = cursor
|
||||
.item()
|
||||
.ok_or_else(|| anyhow!("display point {:?} is out of range", point))?;
|
||||
assert!(transform.display_text.is_none());
|
||||
let end_buffer_offset = (cursor.start().buffer.lines + overshoot)
|
||||
.to_char_offset(self.buffer.as_ref(app))?;
|
||||
offset += end_buffer_offset - cursor.start().buffer.chars;
|
||||
}
|
||||
Ok(offset.into())
|
||||
}
|
||||
|
||||
pub fn to_buffer_point(&self, display_point: DisplayPoint) -> Point {
|
||||
let mut cursor = self.transforms.cursor::<DisplayPoint, TransformSummary>();
|
||||
cursor.seek(&display_point, SeekBias::Right);
|
||||
let overshoot = display_point.0 - cursor.start().display.lines;
|
||||
cursor.start().buffer.lines + overshoot
|
||||
}
|
||||
|
||||
pub fn to_display_point(&self, point: Point) -> DisplayPoint {
|
||||
let mut cursor = self.transforms.cursor::<Point, TransformSummary>();
|
||||
cursor.seek(&point, SeekBias::Right);
|
||||
let overshoot = point - cursor.start().buffer.lines;
|
||||
DisplayPoint(cmp::min(
|
||||
cursor.start().display.lines + overshoot,
|
||||
cursor.end().display.lines,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn apply_edits(&mut self, edits: &[Edit], app: &AppContext) -> Result<()> {
|
||||
let buffer = self.buffer.as_ref(app);
|
||||
let mut edits = edits.iter().cloned().peekable();
|
||||
|
||||
let mut new_transforms = SumTree::new();
|
||||
let mut cursor = self.transforms.cursor::<CharOffset, CharOffset>();
|
||||
cursor.seek(&0.into(), SeekBias::Right);
|
||||
|
||||
while let Some(mut edit) = edits.next() {
|
||||
new_transforms.push_tree(cursor.slice(&edit.old_range.start, SeekBias::Left));
|
||||
edit.new_range.start -= edit.old_range.start - *cursor.start();
|
||||
edit.old_range.start = *cursor.start();
|
||||
|
||||
cursor.seek(&edit.old_range.end, SeekBias::Right);
|
||||
cursor.next();
|
||||
|
||||
let mut delta = edit.delta();
|
||||
loop {
|
||||
edit.old_range.end = *cursor.start();
|
||||
|
||||
if let Some(next_edit) = edits.peek() {
|
||||
if next_edit.old_range.start > edit.old_range.end {
|
||||
break;
|
||||
}
|
||||
|
||||
let next_edit = edits.next().unwrap();
|
||||
delta += next_edit.delta();
|
||||
|
||||
if next_edit.old_range.end > edit.old_range.end {
|
||||
edit.old_range.end = next_edit.old_range.end;
|
||||
cursor.seek(&edit.old_range.end, SeekBias::Right);
|
||||
cursor.next();
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
edit.new_range.end = CharOffset::from(
|
||||
((edit.new_range.start + edit.old_extent()).as_usize() as isize + delta) as usize,
|
||||
);
|
||||
|
||||
let anchor = buffer.anchor_before(edit.new_range.start)?;
|
||||
let folds_start = self
|
||||
.folds
|
||||
.find_insertion_index(|probe| probe.start.cmp(&anchor, buffer))?;
|
||||
let mut folds = self.folds[folds_start..]
|
||||
.iter()
|
||||
.filter_map(|fold| {
|
||||
Some(
|
||||
fold.start.to_char_offset(buffer).ok()?
|
||||
..fold.end.to_char_offset(buffer).ok()?,
|
||||
)
|
||||
})
|
||||
.peekable();
|
||||
|
||||
while folds
|
||||
.peek()
|
||||
.is_some_and(|fold| fold.start < edit.new_range.end)
|
||||
{
|
||||
let mut fold = folds.next().unwrap();
|
||||
let sum = new_transforms.summary();
|
||||
|
||||
assert!(fold.start >= sum.buffer.chars);
|
||||
|
||||
while folds
|
||||
.peek()
|
||||
.is_some_and(|next_fold| next_fold.start <= fold.end)
|
||||
{
|
||||
let next_fold = folds.next().unwrap();
|
||||
if next_fold.end > fold.end {
|
||||
fold.end = next_fold.end;
|
||||
}
|
||||
}
|
||||
|
||||
if fold.start > sum.buffer.chars {
|
||||
let text_summary = buffer.text_summary_for_range(sum.buffer.chars..fold.start);
|
||||
new_transforms.push(Transform {
|
||||
summary: TransformSummary {
|
||||
display: text_summary.clone(),
|
||||
buffer: text_summary,
|
||||
},
|
||||
display_text: None,
|
||||
});
|
||||
}
|
||||
|
||||
if fold.end > fold.start {
|
||||
new_transforms.push(Transform {
|
||||
summary: TransformSummary {
|
||||
display: TextSummary {
|
||||
chars: 1.into(),
|
||||
bytes: '…'.len_utf8().into(),
|
||||
lines: Point::new(0, 1),
|
||||
first_line_len: 1,
|
||||
rightmost_point: Point::new(0, 1),
|
||||
},
|
||||
buffer: buffer.text_summary_for_range(fold.start..fold.end),
|
||||
},
|
||||
display_text: Some('…'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let sum = new_transforms.summary();
|
||||
if sum.buffer.chars < edit.new_range.end {
|
||||
let text_summary =
|
||||
buffer.text_summary_for_range(sum.buffer.chars..edit.new_range.end);
|
||||
new_transforms.push(Transform {
|
||||
summary: TransformSummary {
|
||||
display: text_summary.clone(),
|
||||
buffer: text_summary,
|
||||
},
|
||||
display_text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new_transforms.push_tree(cursor.suffix());
|
||||
|
||||
drop(cursor);
|
||||
self.transforms = new_transforms;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
struct Transform {
|
||||
summary: TransformSummary,
|
||||
display_text: Option<char>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
struct TransformSummary {
|
||||
display: TextSummary,
|
||||
buffer: TextSummary,
|
||||
}
|
||||
|
||||
impl sum_tree::Item for Transform {
|
||||
type Summary = TransformSummary;
|
||||
|
||||
fn summary(&self) -> Self::Summary {
|
||||
self.summary.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::AddAssign<&'a Self> for TransformSummary {
|
||||
fn add_assign(&mut self, other: &'a Self) {
|
||||
self.buffer += &other.buffer;
|
||||
self.display += &other.display;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Dimension<'a, TransformSummary> for TransformSummary {
|
||||
fn add_summary(&mut self, summary: &'a TransformSummary) {
|
||||
*self += summary;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BufferRows<'a> {
|
||||
cursor: Cursor<'a, Transform, DisplayPoint, TransformSummary>,
|
||||
display_point: Point,
|
||||
}
|
||||
|
||||
impl Iterator for BufferRows<'_> {
|
||||
type Item = u32;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while self.display_point > self.cursor.end().display.lines {
|
||||
self.cursor.next();
|
||||
if self.cursor.item().is_none() {
|
||||
// TODO: Return a bool from next?
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if self.cursor.item().is_some() {
|
||||
let overshoot = self.display_point - self.cursor.start().display.lines;
|
||||
let buffer_point = self.cursor.start().buffer.lines + overshoot;
|
||||
self.display_point.row += 1;
|
||||
Some(buffer_point.row)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chars<'a>(CharsWithStyle<'a>);
|
||||
|
||||
impl Iterator for Chars<'_> {
|
||||
type Item = char;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.0.next().map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CharsWithStyle<'a> {
|
||||
cursor: Cursor<'a, Transform, DisplayOffset, TransformSummary>,
|
||||
offset: CharOffset,
|
||||
buffer: &'a Buffer,
|
||||
buffer_chars: Option<Take<buffer::CharsWithStyle<'a>>>,
|
||||
}
|
||||
|
||||
impl Iterator for CharsWithStyle<'_> {
|
||||
type Item = StylizedChar;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.buffer_chars.as_mut().map(Iterator::next) {
|
||||
// buffer_chars is set and has a value, return it
|
||||
Some(Some(c)) => {
|
||||
self.offset += 1;
|
||||
return Some(c);
|
||||
}
|
||||
// buffer_chars is set, but exhausted, so the current cursor item is complete
|
||||
Some(None) => {
|
||||
self.buffer_chars = None;
|
||||
self.cursor.next();
|
||||
}
|
||||
// buffer_chars is not set, if we've returned all of the current cursor item's
|
||||
// characters, then we should move to the next value
|
||||
None => {
|
||||
if self.offset == self.cursor.end().display.chars {
|
||||
self.cursor.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.cursor.item().and_then(|transform| {
|
||||
if let Some(c) = transform.display_text {
|
||||
self.offset += 1;
|
||||
Some(StylizedChar::new(c, TextStyle::default()))
|
||||
} else {
|
||||
let overshoot = self.offset - self.cursor.start().display.chars;
|
||||
let buffer_start = self.cursor.start().buffer.chars + overshoot;
|
||||
let char_count = self.cursor.end().buffer.chars - buffer_start;
|
||||
self.buffer_chars = Some(
|
||||
self.buffer
|
||||
.stylized_chars_at(buffer_start)
|
||||
.unwrap()
|
||||
.take(char_count.as_usize()),
|
||||
);
|
||||
self.next()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Dimension<'a, TransformSummary> for DisplayPoint {
|
||||
fn add_summary(&mut self, summary: &'a TransformSummary) {
|
||||
self.0 += summary.display.lines;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
|
||||
/// The number of _visible_ characters offset from the start of the buffer.
|
||||
pub struct DisplayOffset(usize);
|
||||
|
||||
impl From<usize> for DisplayOffset {
|
||||
fn from(usize: usize) -> Self {
|
||||
DisplayOffset(usize)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CharOffset> for DisplayOffset {
|
||||
fn from(char_offset: CharOffset) -> Self {
|
||||
DisplayOffset(char_offset.as_usize())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Dimension<'a, TransformSummary> for DisplayOffset {
|
||||
fn add_summary(&mut self, summary: &'a TransformSummary) {
|
||||
self.0 += &summary.display.chars.as_usize();
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Dimension<'a, TransformSummary> for Point {
|
||||
fn add_summary(&mut self, summary: &'a TransformSummary) {
|
||||
*self += summary.buffer.lines;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Dimension<'a, TransformSummary> for CharOffset {
|
||||
fn add_summary(&mut self, summary: &'a TransformSummary) {
|
||||
*self += summary.buffer.chars;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fold_map_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,295 @@
|
||||
use super::*;
|
||||
use crate::editor::tests::{sample_text, RandomCharIter};
|
||||
use crate::editor::EditOrigin;
|
||||
use tests::buffer::RangesWhenEditing;
|
||||
use warpui::App;
|
||||
|
||||
#[test]
|
||||
fn test_basic_folds() -> Result<()> {
|
||||
App::test((), |mut app| async move {
|
||||
let buffer = app.add_model(|_| Buffer::new(sample_text(5, 6)));
|
||||
let mut map = app.read(|app| FoldMap::new(buffer.clone(), app));
|
||||
|
||||
app.read(|app| {
|
||||
map.fold(
|
||||
vec![
|
||||
Point::new(0, 2)..Point::new(2, 2),
|
||||
Point::new(2, 4)..Point::new(4, 1),
|
||||
],
|
||||
app,
|
||||
)?;
|
||||
assert_eq!(map.text(app), "aa…cc…eeeee");
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})?;
|
||||
|
||||
let edits = buffer.update(&mut app, |buffer, ctx| {
|
||||
let start_version = buffer.versions();
|
||||
buffer.edit_for_test(
|
||||
vec![
|
||||
Point::new(0, 0)..Point::new(0, 1),
|
||||
Point::new(2, 3)..Point::new(2, 3),
|
||||
],
|
||||
"123",
|
||||
EditOrigin::UserInitiated,
|
||||
ctx,
|
||||
)?;
|
||||
Ok::<_, anyhow::Error>(buffer.edits_since(start_version).collect::<Vec<_>>())
|
||||
})?;
|
||||
|
||||
app.read(|app| {
|
||||
map.apply_edits(&edits, app)?;
|
||||
assert_eq!(map.text(app), "123a…c123c…eeeee");
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})?;
|
||||
|
||||
let edits = buffer.update(&mut app, |buffer, ctx| {
|
||||
let start_version = buffer.versions();
|
||||
buffer.edit_for_test(
|
||||
Some(Point::new(2, 6)..Point::new(4, 3)),
|
||||
"456",
|
||||
EditOrigin::UserInitiated,
|
||||
ctx,
|
||||
)?;
|
||||
Ok::<_, anyhow::Error>(buffer.edits_since(start_version).collect::<Vec<_>>())
|
||||
})?;
|
||||
|
||||
app.read(|app| {
|
||||
map.apply_edits(&edits, app)?;
|
||||
assert_eq!(map.text(app), "123a…c123456eee");
|
||||
|
||||
map.unfold(Some(Point::new(0, 4)..Point::new(0, 4)), app)?;
|
||||
assert_eq!(map.text(app), "123aaaaa\nbbbbbb\nccc123456eee");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overlapping_folds() -> Result<()> {
|
||||
App::test((), |mut app| async move {
|
||||
let buffer = app.add_model(|_| Buffer::new(sample_text(5, 6)));
|
||||
app.read(|app| {
|
||||
let mut map = FoldMap::new(buffer.clone(), app);
|
||||
map.fold(
|
||||
vec![
|
||||
Point::new(0, 2)..Point::new(2, 2),
|
||||
Point::new(0, 4)..Point::new(1, 0),
|
||||
Point::new(1, 2)..Point::new(3, 2),
|
||||
Point::new(3, 1)..Point::new(4, 1),
|
||||
],
|
||||
app,
|
||||
)?;
|
||||
assert_eq!(map.text(app), "aa…eeeee");
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merging_folds_via_edit() -> Result<()> {
|
||||
App::test((), |mut app| async move {
|
||||
let buffer = app.add_model(|_| Buffer::new(sample_text(5, 6)));
|
||||
let mut map = app.read(|app| FoldMap::new(buffer.clone(), app));
|
||||
|
||||
app.read(|app| {
|
||||
map.fold(
|
||||
vec![
|
||||
Point::new(0, 2)..Point::new(2, 2),
|
||||
Point::new(3, 1)..Point::new(4, 1),
|
||||
],
|
||||
app,
|
||||
)?;
|
||||
assert_eq!(map.text(app), "aa…cccc\nd…eeeee");
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})?;
|
||||
|
||||
let edits = buffer.update(&mut app, |buffer, ctx| {
|
||||
let start_version = buffer.versions();
|
||||
buffer.edit_for_test(
|
||||
Some(Point::new(2, 2)..Point::new(3, 1)),
|
||||
"",
|
||||
EditOrigin::UserInitiated,
|
||||
ctx,
|
||||
)?;
|
||||
Ok::<_, anyhow::Error>(buffer.edits_since(start_version).collect::<Vec<_>>())
|
||||
})?;
|
||||
|
||||
app.read(|app| {
|
||||
map.apply_edits(&edits, app)?;
|
||||
assert_eq!(map.text(app), "aa…eeeee");
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random_folds() -> Result<()> {
|
||||
use super::super::buffer::ToPoint;
|
||||
use rand::prelude::*;
|
||||
|
||||
for seed in 0..100 {
|
||||
println!("{seed:?}");
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let buffer = app.add_model(|_| {
|
||||
let len = rng.gen_range(0..10);
|
||||
let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
|
||||
Buffer::new(text)
|
||||
});
|
||||
let mut map = app.read(|app| FoldMap::new(buffer.clone(), app));
|
||||
|
||||
app.read(|app| -> Result<()> {
|
||||
let buffer = buffer.as_ref(app);
|
||||
|
||||
let fold_count = rng.gen_range(0..10);
|
||||
let mut fold_ranges: Vec<Range<CharOffset>> = Vec::new();
|
||||
for _ in 0..fold_count {
|
||||
let end = rng.gen_range(0..buffer.len().as_usize() + 1);
|
||||
let start = rng.gen_range(0..end + 1);
|
||||
fold_ranges.push(CharOffset::from(start)..CharOffset::from(end));
|
||||
}
|
||||
|
||||
map.fold(fold_ranges, app)?;
|
||||
|
||||
let mut expected_text = buffer.text();
|
||||
for fold_range in map.merged_fold_ranges(app).into_iter().rev() {
|
||||
expected_text
|
||||
.replace_range(fold_range.start.as_usize()..fold_range.end.as_usize(), "…");
|
||||
}
|
||||
|
||||
assert_eq!(map.text(app), expected_text);
|
||||
|
||||
for fold_range in map.merged_fold_ranges(app) {
|
||||
let display_point =
|
||||
map.to_display_point(fold_range.start.to_point(buffer).unwrap());
|
||||
assert!(map.is_line_folded(display_point.row()));
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})?;
|
||||
|
||||
let edits = buffer.update(&mut app, |buffer, ctx| {
|
||||
let start_version = buffer.versions();
|
||||
let edit_count = rng.gen_range(1..10);
|
||||
buffer.randomly_edit(
|
||||
&mut rng,
|
||||
RangesWhenEditing::UseRandomRanges {
|
||||
num_ranges: edit_count,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
Ok::<_, anyhow::Error>(buffer.edits_since(start_version).collect::<Vec<_>>())
|
||||
})?;
|
||||
|
||||
app.read(|app| {
|
||||
map.apply_edits(&edits, app)?;
|
||||
|
||||
let buffer = map.buffer.as_ref(app);
|
||||
let mut expected_text = buffer.text();
|
||||
for fold_range in map.merged_fold_ranges(app).into_iter().rev() {
|
||||
expected_text
|
||||
.replace_range(fold_range.start.as_usize()..fold_range.end.as_usize(), "…");
|
||||
}
|
||||
assert_eq!(map.text(app), expected_text);
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})?;
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_rows() -> Result<()> {
|
||||
App::test((), |mut app| async move {
|
||||
let text = sample_text(6, 6) + "\n";
|
||||
let buffer = app.add_model(|_| Buffer::new(text));
|
||||
|
||||
app.read(|app| {
|
||||
let mut map = FoldMap::new(buffer.clone(), app);
|
||||
|
||||
map.fold(
|
||||
vec![
|
||||
Point::new(0, 2)..Point::new(2, 2),
|
||||
Point::new(3, 1)..Point::new(4, 1),
|
||||
],
|
||||
app,
|
||||
)?;
|
||||
|
||||
assert_eq!(map.text(app), "aa…cccc\nd…eeeee\nffffff\n");
|
||||
assert_eq!(map.buffer_rows(0)?.collect::<Vec<_>>(), vec![0, 3, 5, 6]);
|
||||
assert_eq!(map.buffer_rows(3)?.collect::<Vec<_>>(), vec![6]);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desynced_buffer() {
|
||||
App::test((), |mut app| async move {
|
||||
// Create a buffer with 20 characters
|
||||
let buffer = app.add_model(|_| Buffer::new(sample_text(1, 20)));
|
||||
app.update(|app| {
|
||||
let map = FoldMap::new(buffer.clone(), app);
|
||||
buffer.update(app, |buf, ctx| {
|
||||
// Update the buffer to only have 10 characters
|
||||
buf.edit_for_test(
|
||||
Some(0..20),
|
||||
sample_text(1, 10),
|
||||
EditOrigin::UserInitiated,
|
||||
ctx,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let mut chars = map.chars_at(DisplayPoint::new(0, 10), app).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
chars.next(),
|
||||
None,
|
||||
"Shouldn't attempt to return unavailable characters"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
impl FoldMap {
|
||||
fn text(&self, app: &AppContext) -> String {
|
||||
self.chars_at(DisplayPoint(Point::zero()), app)
|
||||
.unwrap()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn merged_fold_ranges(&self, app: &AppContext) -> Vec<Range<CharOffset>> {
|
||||
let buffer = self.buffer.as_ref(app);
|
||||
let mut fold_ranges = self
|
||||
.folds
|
||||
.iter()
|
||||
.map(|fold| {
|
||||
fold.start.to_char_offset(buffer).unwrap()..fold.end.to_char_offset(buffer).unwrap()
|
||||
})
|
||||
.peekable();
|
||||
|
||||
let mut merged_ranges = Vec::new();
|
||||
while let Some(mut fold_range) = fold_ranges.next() {
|
||||
while let Some(next_range) = fold_ranges.peek() {
|
||||
if fold_range.end >= next_range.start {
|
||||
if next_range.end > fold_range.end {
|
||||
fold_range.end = next_range.end;
|
||||
}
|
||||
fold_ranges.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if fold_range.end > fold_range.start {
|
||||
merged_ranges.push(fold_range);
|
||||
}
|
||||
}
|
||||
merged_ranges
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
mod fold_map;
|
||||
|
||||
use super::buffer::{self, Anchor, Buffer, Edit, StylizedChar, ToCharOffset, ToPoint};
|
||||
use crate::editor::soft_wrap::{self, DisplayPointAndClampDirection, SoftWrapPoint, SoftWrapState};
|
||||
use anyhow::{Context, Result};
|
||||
pub use fold_map::BufferRows;
|
||||
use fold_map::FoldMap;
|
||||
use std::cmp;
|
||||
use std::ops::Range;
|
||||
use string_offset::CharOffset;
|
||||
use warpui::text::point::Point;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum Bias {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
pub struct DisplayMap {
|
||||
buffer: ModelHandle<Buffer>,
|
||||
fold_map: FoldMap,
|
||||
soft_wrap_state: SoftWrapState,
|
||||
tab_size: usize,
|
||||
}
|
||||
|
||||
pub struct MovementResult {
|
||||
pub point_and_clamp_direction: DisplayPointAndClampDirection,
|
||||
/// True if the resulting point is the same row as the original row.
|
||||
pub is_same_row: bool,
|
||||
/// The desired column based on the original point. This could be higher than
|
||||
/// the returned column in point because the current row may not have had as
|
||||
/// many columns as the previous row.
|
||||
pub goal_column: u32,
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Folded,
|
||||
Unfolded,
|
||||
}
|
||||
|
||||
impl Entity for DisplayMap {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl DisplayMap {
|
||||
fn new_internal(
|
||||
buffer: ModelHandle<Buffer>,
|
||||
subscribe_to_buffer: bool,
|
||||
tab_size: usize,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
if subscribe_to_buffer {
|
||||
ctx.subscribe_to_model(&buffer, Self::handle_buffer_event);
|
||||
}
|
||||
|
||||
DisplayMap {
|
||||
buffer: buffer.clone(),
|
||||
fold_map: FoldMap::new(buffer, ctx),
|
||||
soft_wrap_state: Default::default(),
|
||||
tab_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(buffer: ModelHandle<Buffer>, tab_size: usize, ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::new_internal(buffer, true, tab_size, ctx)
|
||||
}
|
||||
|
||||
pub fn recreate(&mut self, tab_size: usize, ctx: &mut ModelContext<Self>) {
|
||||
*self = Self::new_internal(self.buffer.clone(), false, tab_size, ctx);
|
||||
}
|
||||
|
||||
pub fn buffer<'a>(&self, app: &'a AppContext) -> &'a Buffer {
|
||||
self.buffer.as_ref(app)
|
||||
}
|
||||
|
||||
pub fn soft_wrap_state(&self) -> SoftWrapState {
|
||||
self.soft_wrap_state.clone()
|
||||
}
|
||||
|
||||
pub fn fold<T: ToCharOffset>(
|
||||
&mut self,
|
||||
ranges: impl IntoIterator<Item = Range<T>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<()> {
|
||||
self.fold_map.fold(ranges, ctx)?;
|
||||
ctx.emit(Event::Folded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unfold<T: ToCharOffset>(
|
||||
&mut self,
|
||||
ranges: impl IntoIterator<Item = Range<T>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<()> {
|
||||
self.fold_map.unfold(ranges, ctx)?;
|
||||
ctx.emit(Event::Unfolded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TODO(zheng) Consolidate logic with `down`.
|
||||
pub fn up(
|
||||
&self,
|
||||
point: DisplayPoint,
|
||||
goal_column: Option<u32>,
|
||||
clamp_direction: soft_wrap::ClampDirection,
|
||||
) -> Result<MovementResult> {
|
||||
self.soft_wrap_state.read(|frame_layouts| {
|
||||
let frame_layouts = frame_layouts.map_err(|err| anyhow::anyhow!("Could not read Frame Layouts {:?}", err))?;
|
||||
let point = frame_layouts.to_soft_wrap_point(point, clamp_direction).context("Point is out of bounds of laid out text")?;
|
||||
|
||||
let line = frame_layouts
|
||||
.get_line(point.row() as usize)
|
||||
.context("Should have current line as it should not be possible for it to be beyond bounds, but was not able to retrieve it")?;
|
||||
// Note that the index here will correspond to that of the original text so if
|
||||
// this line is soft wrapped, it will be higher than 0.
|
||||
let line_first_index = line.first_glyph().map_or(0, |glyph| glyph.index);
|
||||
|
||||
let relative_index = point.column() - line_first_index as u32;
|
||||
|
||||
// Note that, as also mentioned in the rustdoc of this function,
|
||||
// the goal column is a relative index to the first point of the row
|
||||
// so it starts at 0 and is at most the length of a row (but it could
|
||||
// be higher than the current row as it is preserved across rows).
|
||||
let goal_column = match goal_column {
|
||||
Some(goal_column) => cmp::max(goal_column, relative_index),
|
||||
None => relative_index,
|
||||
};
|
||||
let (point, is_same_row) = if point.row() > 0 {
|
||||
let prev_line = frame_layouts
|
||||
.get_line(point.row() as usize - 1)
|
||||
.context("Should have next line based on max point bounds check, but was not able to retrieve it")?;
|
||||
let prev_line_first_column = prev_line.first_glyph().map_or(0, |glyph| glyph.index as u32);
|
||||
// Note that goal column is a relative value, rather than based
|
||||
// on the original string.
|
||||
let new_column = prev_line_first_column + goal_column;
|
||||
|
||||
let last_index_in_line = prev_line.last_glyph().map(|glyph| glyph.index as u32 + 1)
|
||||
.unwrap_or(0);
|
||||
|
||||
(SoftWrapPoint::new(
|
||||
point.row() - 1,
|
||||
cmp::min(new_column, last_index_in_line),
|
||||
), false)
|
||||
} else {
|
||||
(SoftWrapPoint::new(0, 0), true)
|
||||
};
|
||||
Ok(MovementResult {
|
||||
point_and_clamp_direction: frame_layouts.to_display_point(point),
|
||||
goal_column,
|
||||
is_same_row,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Given a `DisplayPoint` and the goal column (i.e. the rightmost column
|
||||
/// that we _want_ to be at which could be higher than the current point's
|
||||
/// column) returns the point and new goal column we would be at if we were
|
||||
/// to navigate one row down from the point with soft wrap in consideration.
|
||||
///
|
||||
/// Note that while `goal_column` is the column number of the row, the column
|
||||
/// numbers in `SoftWrapPoint`, are relative to the original string.
|
||||
/// TODO(zheng) Consolidate logic with `up`.
|
||||
pub fn down(
|
||||
&self,
|
||||
point: DisplayPoint,
|
||||
goal_column: Option<u32>,
|
||||
clamp_direction: soft_wrap::ClampDirection,
|
||||
) -> Result<MovementResult> {
|
||||
self.soft_wrap_state.read(|frame_layouts| {
|
||||
let frame_layouts = frame_layouts.map_err(|err| anyhow::anyhow!("Could not read Frame Layouts {:?}", err))?;
|
||||
let point = frame_layouts.to_soft_wrap_point(point, clamp_direction).ok_or_else(|| anyhow::anyhow!("Point is out of bounds of laid out text"))?;
|
||||
|
||||
let line = frame_layouts
|
||||
.get_line(point.row() as usize)
|
||||
.context("Should have current line as it should not be possible for it to be beyond bounds, but was not able to retrieve it")?;
|
||||
// Note that the index here will correspond to that of the original text so if
|
||||
// this line is soft wrapped, it will be higher than 0.
|
||||
let line_first_index = line.first_glyph().map_or(0, |glyph| glyph.index);
|
||||
|
||||
let relative_index = point.column() - line_first_index as u32;
|
||||
|
||||
// Note that, as also mentioned in the rustdoc of this function,
|
||||
// the goal column is a relative index to the first point of the row
|
||||
// so it starts at 0 and is at most the length of a row (but it could
|
||||
// be higher than the current row as it is preserved across rows).
|
||||
let goal_column = match goal_column {
|
||||
Some(goal_column) => cmp::max(goal_column, relative_index),
|
||||
None => relative_index,
|
||||
};
|
||||
let max_row = frame_layouts.num_lines() - 1;
|
||||
let max_col = frame_layouts
|
||||
.get_line(max_row)
|
||||
.and_then(|line| line.last_glyph().map(|glyph| glyph.index + 1))
|
||||
.unwrap_or(0);
|
||||
let max_point = SoftWrapPoint::new(max_row as u32, max_col as u32);
|
||||
let (point, is_same_row) = if point.row() < max_point.row() {
|
||||
let next_line = frame_layouts
|
||||
.get_line(point.row() as usize + 1)
|
||||
.context("Should have next line based on max point bounds check, but was not able to retrieve it")?;
|
||||
let next_line_first_column = next_line.first_glyph().map_or(0, |glyph| glyph.index as u32);
|
||||
// Note that goal column is a relative value, rather than based
|
||||
// on the original string.
|
||||
let new_column = next_line_first_column + goal_column;
|
||||
|
||||
let last_index_in_line = next_line.last_glyph().map(|glyph| glyph.index as u32 + 1)
|
||||
.unwrap_or(0);
|
||||
|
||||
(SoftWrapPoint::new(
|
||||
point.row() + 1,
|
||||
cmp::min(new_column, last_index_in_line),
|
||||
), false)
|
||||
} else {
|
||||
(max_point, true)
|
||||
};
|
||||
Ok(MovementResult {
|
||||
point_and_clamp_direction: frame_layouts.to_display_point(point),
|
||||
goal_column,
|
||||
is_same_row,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_soft_wrap_point(
|
||||
&self,
|
||||
point: DisplayPoint,
|
||||
clamp_direction: soft_wrap::ClampDirection,
|
||||
) -> Option<SoftWrapPoint> {
|
||||
self.soft_wrap_state
|
||||
.read(|frame_layouts| match frame_layouts {
|
||||
Ok(frame_layouts) => frame_layouts.to_soft_wrap_point(point, clamp_direction),
|
||||
Err(err) => {
|
||||
log::warn!("Error attempting to get soft wrap point {err:?}");
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_line_folded(&self, display_row: u32) -> bool {
|
||||
self.fold_map.is_line_folded(display_row)
|
||||
}
|
||||
|
||||
pub fn tab_size(&self) -> usize {
|
||||
self.tab_size
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn text(&self, app: &AppContext) -> String {
|
||||
self.chars_at(DisplayPoint::zero(), app).unwrap().collect()
|
||||
}
|
||||
|
||||
pub fn line(&self, display_row: u32, app: &AppContext) -> Result<String> {
|
||||
let chars = self.chars_at(DisplayPoint::new(display_row, 0), app)?;
|
||||
Ok(chars.take_while(|c| *c != '\n').collect())
|
||||
}
|
||||
|
||||
pub fn chars_with_styles_at<'a>(
|
||||
&'a self,
|
||||
point: DisplayPoint,
|
||||
app: &'a AppContext,
|
||||
) -> Result<impl 'a + Iterator<Item = StylizedChar>> {
|
||||
let column = point.column() as usize;
|
||||
let (point, to_next_stop) = point.collapse_tabs(self, Bias::Left, app)?;
|
||||
let mut fold_chars = self.fold_map.chars_with_style_at(point, app)?;
|
||||
if to_next_stop > 0 {
|
||||
fold_chars.next();
|
||||
}
|
||||
|
||||
Ok(CharsWithStyles {
|
||||
fold_chars,
|
||||
column,
|
||||
to_next_stop,
|
||||
tab_size: self.tab_size,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn chars_at<'a>(
|
||||
&'a self,
|
||||
point: DisplayPoint,
|
||||
app: &'a AppContext,
|
||||
) -> Result<impl 'a + Iterator<Item = char>> {
|
||||
Ok(self.chars_with_styles_at(point, app)?.map(Into::into))
|
||||
}
|
||||
|
||||
pub fn buffer_rows(&self, start_row: u32) -> Result<BufferRows<'_>> {
|
||||
self.fold_map.buffer_rows(start_row)
|
||||
}
|
||||
|
||||
pub fn line_len(&self, row: u32, ctx: &AppContext) -> Result<u32> {
|
||||
DisplayPoint::new(row, self.fold_map.line_len(row, ctx)?)
|
||||
.expand_tabs(self, ctx)
|
||||
.map(|point| point.column())
|
||||
}
|
||||
|
||||
pub fn max_point(&self, app: &AppContext) -> DisplayPoint {
|
||||
self.fold_map.max_point().expand_tabs(self, app).unwrap()
|
||||
}
|
||||
|
||||
pub fn rightmost_point(&self) -> DisplayPoint {
|
||||
self.fold_map.rightmost_point()
|
||||
}
|
||||
|
||||
pub fn anchor_before(
|
||||
&self,
|
||||
point: DisplayPoint,
|
||||
bias: Bias,
|
||||
app: &AppContext,
|
||||
) -> Result<Anchor> {
|
||||
self.buffer
|
||||
.as_ref(app)
|
||||
.anchor_before(point.to_buffer_point(self, bias, app)?)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn anchor_after(
|
||||
&self,
|
||||
point: DisplayPoint,
|
||||
bias: Bias,
|
||||
app: &AppContext,
|
||||
) -> Result<Anchor> {
|
||||
self.buffer
|
||||
.as_ref(app)
|
||||
.anchor_after(point.to_buffer_point(self, bias, app)?)
|
||||
}
|
||||
|
||||
pub fn apply_edits(&mut self, edits: &[Edit], ctx: &AppContext) -> Result<()> {
|
||||
self.fold_map.apply_edits(edits, ctx)
|
||||
}
|
||||
|
||||
fn handle_buffer_event(&mut self, event: &buffer::Event, ctx: &mut ModelContext<Self>) {
|
||||
match event {
|
||||
buffer::Event::Edited { edits, .. } => self.apply_edits(edits, ctx).unwrap(),
|
||||
buffer::Event::StylesUpdated
|
||||
| buffer::Event::UpdatePeers { .. }
|
||||
| buffer::Event::SelectionsChanged => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
|
||||
pub struct DisplayPoint(Point);
|
||||
|
||||
impl DisplayPoint {
|
||||
pub fn new(row: u32, column: u32) -> Self {
|
||||
Self(Point::new(row, column))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn zero() -> Self {
|
||||
Self::new(0, 0)
|
||||
}
|
||||
|
||||
pub fn row(self) -> u32 {
|
||||
self.0.row
|
||||
}
|
||||
|
||||
pub fn column(self) -> u32 {
|
||||
self.0.column
|
||||
}
|
||||
|
||||
pub fn row_mut(&mut self) -> &mut u32 {
|
||||
&mut self.0.row
|
||||
}
|
||||
|
||||
pub fn column_mut(&mut self) -> &mut u32 {
|
||||
&mut self.0.column
|
||||
}
|
||||
|
||||
pub fn to_buffer_point(self, map: &DisplayMap, bias: Bias, app: &AppContext) -> Result<Point> {
|
||||
Ok(map
|
||||
.fold_map
|
||||
.to_buffer_point(self.collapse_tabs(map, bias, app)?.0))
|
||||
}
|
||||
|
||||
pub fn to_char_offset(
|
||||
self,
|
||||
map: &DisplayMap,
|
||||
bias: Bias,
|
||||
buffer: &Buffer,
|
||||
app: &AppContext,
|
||||
) -> Result<CharOffset> {
|
||||
let point = self.to_buffer_point(map, bias, app)?;
|
||||
point.to_char_offset(buffer)
|
||||
}
|
||||
|
||||
fn expand_tabs(mut self, map: &DisplayMap, app: &AppContext) -> Result<Self> {
|
||||
let chars = map
|
||||
.fold_map
|
||||
.chars_at(DisplayPoint(Point::new(self.row(), 0)), app)?;
|
||||
let expanded = expand_tabs(chars, self.column() as usize, map.tab_size);
|
||||
*self.column_mut() = expanded as u32;
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn collapse_tabs(
|
||||
mut self,
|
||||
map: &DisplayMap,
|
||||
bias: Bias,
|
||||
app: &AppContext,
|
||||
) -> Result<(Self, usize)> {
|
||||
let chars = map
|
||||
.fold_map
|
||||
.chars_at(DisplayPoint(Point::new(self.0.row, 0)), app)?;
|
||||
let expanded = self.column() as usize;
|
||||
let (collapsed, to_next_stop) = collapse_tabs(chars, expanded, bias, map.tab_size);
|
||||
*self.column_mut() = collapsed as u32;
|
||||
|
||||
Ok((self, to_next_stop))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for types that can map onto a display point, accounting for soft-wrapping
|
||||
/// and text folding.
|
||||
pub trait ToDisplayPoint {
|
||||
fn to_display_point(self, map: &DisplayMap, app: &AppContext) -> Result<DisplayPoint>;
|
||||
}
|
||||
|
||||
impl ToDisplayPoint for Point {
|
||||
fn to_display_point(self, map: &DisplayMap, app: &AppContext) -> Result<DisplayPoint> {
|
||||
let mut display_point = map.fold_map.to_display_point(self);
|
||||
let chars = map
|
||||
.fold_map
|
||||
.chars_at(DisplayPoint::new(display_point.row(), 0), app)?;
|
||||
*display_point.column_mut() =
|
||||
expand_tabs(chars, display_point.column() as usize, map.tab_size) as u32;
|
||||
Ok(display_point)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToDisplayPoint for &Anchor {
|
||||
fn to_display_point(self, map: &DisplayMap, app: &AppContext) -> Result<DisplayPoint> {
|
||||
self.to_point(map.buffer.as_ref(app))?
|
||||
.to_display_point(map, app)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CharsWithStyles<'a> {
|
||||
fold_chars: fold_map::CharsWithStyle<'a>,
|
||||
column: usize,
|
||||
to_next_stop: usize,
|
||||
tab_size: usize,
|
||||
}
|
||||
|
||||
impl Iterator for CharsWithStyles<'_> {
|
||||
type Item = StylizedChar;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.to_next_stop > 0 {
|
||||
self.to_next_stop -= 1;
|
||||
self.column += 1;
|
||||
Some(StylizedChar::new(' ', Default::default()))
|
||||
} else {
|
||||
self.fold_chars.next().map(|c| match c.char() {
|
||||
'\t' => {
|
||||
self.to_next_stop = self.tab_size - self.column % self.tab_size - 1;
|
||||
self.column += 1;
|
||||
StylizedChar::new(' ', c.style())
|
||||
}
|
||||
'\n' => {
|
||||
self.column = 0;
|
||||
c
|
||||
}
|
||||
_ => {
|
||||
self.column += 1;
|
||||
c
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_tabs(chars: impl Iterator<Item = char>, column: usize, tab_size: usize) -> usize {
|
||||
let mut expanded = 0;
|
||||
for c in chars.take(column) {
|
||||
if c == '\t' {
|
||||
expanded += tab_size - expanded % tab_size;
|
||||
} else {
|
||||
expanded += 1;
|
||||
}
|
||||
}
|
||||
expanded
|
||||
}
|
||||
|
||||
pub fn collapse_tabs(
|
||||
chars: impl Iterator<Item = char>,
|
||||
column: usize,
|
||||
bias: Bias,
|
||||
tab_size: usize,
|
||||
) -> (usize, usize) {
|
||||
let mut expanded = 0;
|
||||
let mut collapsed = 0;
|
||||
for c in chars {
|
||||
if expanded == column {
|
||||
break;
|
||||
}
|
||||
|
||||
if c == '\t' {
|
||||
expanded += tab_size - (expanded % tab_size);
|
||||
if expanded > column {
|
||||
return match bias {
|
||||
Bias::Left => (collapsed, expanded - column),
|
||||
Bias::Right => (collapsed + 1, 0),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
expanded += 1;
|
||||
}
|
||||
collapsed += 1;
|
||||
}
|
||||
(collapsed, 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::editor::tests::sample_text;
|
||||
|
||||
use super::*;
|
||||
use crate::editor::EditOrigin;
|
||||
use anyhow::Error;
|
||||
use warpui::App;
|
||||
|
||||
#[test]
|
||||
fn test_chars_at() -> Result<()> {
|
||||
App::test((), |mut app| async move {
|
||||
let text = sample_text(6, 6);
|
||||
let buffer = app.add_model(|_| Buffer::new(text));
|
||||
let map = app.add_model(|ctx| DisplayMap::new(buffer.clone(), 4, ctx));
|
||||
buffer.update(&mut app, |buffer, ctx| {
|
||||
buffer.edit_for_test(
|
||||
vec![
|
||||
Point::new(1, 0)..Point::new(1, 0),
|
||||
Point::new(1, 1)..Point::new(1, 1),
|
||||
Point::new(2, 1)..Point::new(2, 1),
|
||||
],
|
||||
"\t",
|
||||
EditOrigin::UserInitiated,
|
||||
ctx,
|
||||
)
|
||||
})?;
|
||||
|
||||
map.read(&app, |map, ctx| {
|
||||
assert_eq!(
|
||||
map.chars_at(DisplayPoint::new(1, 0), ctx)?
|
||||
.take(10)
|
||||
.collect::<String>(),
|
||||
" b bb"
|
||||
);
|
||||
assert_eq!(
|
||||
map.chars_at(DisplayPoint::new(1, 2), ctx)?
|
||||
.take(10)
|
||||
.collect::<String>(),
|
||||
" b bbbb"
|
||||
);
|
||||
assert_eq!(
|
||||
map.chars_at(DisplayPoint::new(1, 6), ctx)?
|
||||
.take(13)
|
||||
.collect::<String>(),
|
||||
" bbbbb\nc c"
|
||||
);
|
||||
|
||||
Ok::<(), Error>(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tabs() {
|
||||
assert_eq!(expand_tabs("\t".chars(), 0, 4), 0);
|
||||
assert_eq!(expand_tabs("\t".chars(), 1, 4), 4);
|
||||
assert_eq!(expand_tabs("\ta".chars(), 2, 4), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collapse_tabs() {
|
||||
assert_eq!(collapse_tabs("\t".chars(), 0, Bias::Left, 4), (0, 0));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 0, Bias::Right, 4), (0, 0));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 1, Bias::Left, 4), (0, 3));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 1, Bias::Right, 4), (1, 0));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 2, Bias::Left, 4), (0, 2));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 2, Bias::Right, 4), (1, 0));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 3, Bias::Left, 4), (0, 1));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 3, Bias::Right, 4), (1, 0));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 4, Bias::Left, 4), (1, 0));
|
||||
assert_eq!(collapse_tabs("\t".chars(), 4, Bias::Right, 4), (1, 0));
|
||||
assert_eq!(collapse_tabs("\ta".chars(), 5, Bias::Left, 4), (2, 0));
|
||||
assert_eq!(collapse_tabs("\ta".chars(), 5, Bias::Right, 4), (2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_point() -> Result<()> {
|
||||
App::test((), |mut app| async move {
|
||||
let buffer = app.add_model(|_| Buffer::new("aaa\n\t\tbbb"));
|
||||
let map = app.add_model(|ctx| DisplayMap::new(buffer.clone(), 4, ctx));
|
||||
map.read(&app, |map, app| {
|
||||
assert_eq!(map.max_point(app), DisplayPoint::new(1, 11))
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,723 @@
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use warpui::{text_layout::TextStyle, App};
|
||||
|
||||
use crate::editor::{EditorSnapshot, PlainTextEditorViewAction, TextRun, ValidInputType};
|
||||
|
||||
use super::{EditOrigin, EditorModel, Edits, InteractionState, UpdateBufferOption};
|
||||
use vec1::vec1;
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_change_buffer_without_edit() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
// This should panic because we didn't edit the buffer via [`EditorModel::edit`].
|
||||
model.update(&mut app, |model, ctx| model.insert("hello", None, ctx))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_change_selections() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("abc".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(0)..ByteOffset::from(0)
|
||||
);
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_change_selections(|model, ctx| {
|
||||
model.cursor_line_end(/* keep_selection */ false, ctx);
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(3)..ByteOffset::from(3)
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_update_buffer() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.insert("hello", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello"));
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.insert("world", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
|
||||
});
|
||||
|
||||
// `edit` ensures we add stuff to the undo stack correctly, so undo'ing
|
||||
// should bring us back to "hello".
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.undo(ctx);
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello"));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_update_buffer_ignoring_undo() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
assert!(model.last_action(ctx).is_none());
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer_options(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
UpdateBufferOption::SkipUndoRedoRecord,
|
||||
|model, ctx| model.insert("hello", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer_options(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
UpdateBufferOption::SkipUndoRedoRecord,
|
||||
|model, ctx| model.insert("world", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
|
||||
});
|
||||
|
||||
// Since we edited and ignored undo, undo should be a no-op.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.undo(ctx);
|
||||
assert_eq!(model.buffer_text(ctx), "helloworld".to_string());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_post_buffer_edit_change_selections() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(0)..ByteOffset::from(0)
|
||||
);
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new()
|
||||
.with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.insert("hello", None, ctx),
|
||||
)
|
||||
.with_post_buffer_edit_change_selections(|buffer, ctx| {
|
||||
buffer.move_to_buffer_end(false, ctx);
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello"));
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(5)..ByteOffset::from(5)
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_merge_selections() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app
|
||||
.add_model(|ctx| EditorModel::new("hello".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "hello".to_string());
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(0)..ByteOffset::from(0)
|
||||
);
|
||||
});
|
||||
|
||||
// Duplicate the first selection (the cursor).
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_change_selections(|model, ctx| {
|
||||
let first_selection = model.first_selection(ctx).to_owned();
|
||||
model.change_selections(vec1![first_selection.clone(), first_selection], ctx);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// `edit` should merge selections and de-dupe.
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "hello".to_string());
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(0)..ByteOffset::from(0)
|
||||
);
|
||||
});
|
||||
|
||||
// Add a cursor before h and after h, and then backspace.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new()
|
||||
.with_change_selections(|model, ctx| {
|
||||
model
|
||||
.select_ranges_by_offset(
|
||||
[
|
||||
CharOffset::from(0)..CharOffset::from(0),
|
||||
CharOffset::from(1)..CharOffset::from(1),
|
||||
],
|
||||
ctx,
|
||||
)
|
||||
.expect("can select ranges by offset")
|
||||
})
|
||||
.with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.backspace(ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
// The cursors should collapse into one.
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "ello".to_string());
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(0)..ByteOffset::from(0)
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_when_interaction_disabled() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_model(|ctx| {
|
||||
let mut model = EditorModel::new("".into(), 0, None, ValidInputType::All, ctx);
|
||||
model.set_interaction_state(InteractionState::Disabled);
|
||||
model
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(0)..ByteOffset::from(0)
|
||||
);
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new()
|
||||
.with_change_selections(|model, ctx| {
|
||||
model.cursor_line_end(/* keep_selection */ false, ctx);
|
||||
})
|
||||
.with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.insert("hello", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
// Neither the text nor selection state should have changed.
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(0)..ByteOffset::from(0)
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_when_only_selectable() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_model(|ctx| {
|
||||
let mut model = EditorModel::new("hello".into(), 0, None, ValidInputType::All, ctx);
|
||||
model.set_interaction_state(InteractionState::Selectable);
|
||||
model
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "hello".to_string());
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(0)..ByteOffset::from(0)
|
||||
);
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new()
|
||||
.with_change_selections(|model, ctx| {
|
||||
model.cursor_line_end(/* keep_selection */ false, ctx);
|
||||
})
|
||||
.with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.insert("world", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
// Only the selection state should have changed.
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "hello".to_string());
|
||||
|
||||
assert!(model.is_single_cursor_only(ctx));
|
||||
let selection = model.first_selection(ctx);
|
||||
assert_eq!(
|
||||
model.selection_to_byte_offset(selection, ctx),
|
||||
ByteOffset::from(5)..ByteOffset::from(5)
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ephemeral_edit() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
assert!(!model.is_ephemeral());
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.insert("hello", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
// Ephemeral edits should use the latest buffer as the "base text".
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer_options(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
UpdateBufferOption::IsEphemeral,
|
||||
|model, ctx| model.insert("world", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| model.undo(ctx));
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello"));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_materialize_ephemeral_edit() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
assert!(!model.is_ephemeral());
|
||||
});
|
||||
|
||||
// Make an ephemeral edit.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer_options(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
UpdateBufferOption::IsEphemeral,
|
||||
|model, ctx| model.insert("hello world", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello world"));
|
||||
});
|
||||
|
||||
// Materialize it.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new()
|
||||
.with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.insert("earth", None, ctx),
|
||||
)
|
||||
.with_change_selections(|model, ctx| {
|
||||
model.select_word_left(ctx);
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello earth"));
|
||||
});
|
||||
|
||||
// Materialized ephemeral edits should be properly put on the undo stack.
|
||||
model.update(&mut app, |model, ctx| model.undo(ctx));
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello world"));
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| model.undo(ctx));
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_ephemeral());
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_ephemeral_edit_with_ephemeral() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
assert!(!model.is_ephemeral());
|
||||
});
|
||||
|
||||
// Make an ephemeral edit.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer_options(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
UpdateBufferOption::IsEphemeral,
|
||||
|model, ctx| model.insert("hello", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello"));
|
||||
});
|
||||
|
||||
// Make another ephemeral edit.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer_options(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
UpdateBufferOption::IsEphemeral,
|
||||
|model, ctx| model.insert("world", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
|
||||
});
|
||||
|
||||
// Materialize the edits.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| model.insert("!", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("helloworld!"));
|
||||
});
|
||||
|
||||
// There should only be two entries on the undo stack: the non-ephemeral edit and one edit for the ephemeral edits.
|
||||
model.update(&mut app, |model, ctx| model.undo(ctx));
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| model.undo(ctx));
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_ephemeral());
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_change_doesnt_materialize_ephemeral_edit() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
assert!(!model.is_ephemeral());
|
||||
});
|
||||
|
||||
// Make an ephemeral edit.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer_options(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
UpdateBufferOption::IsEphemeral,
|
||||
|model, ctx| model.insert("hello", None, ctx),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.is_ephemeral());
|
||||
assert_eq!(model.buffer_text(ctx), String::from("hello"));
|
||||
});
|
||||
|
||||
// Change selections.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_change_selections(|model, ctx| model.select_word_left(ctx)),
|
||||
)
|
||||
});
|
||||
|
||||
// The ephemeral buffer should still be active.
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(!model.selections(ctx).is_empty());
|
||||
assert!(model.is_ephemeral());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Regression test for CORE-1549.
|
||||
#[test]
|
||||
fn test_restoring_invalid_selections() {
|
||||
App::test((), |mut app| async move {
|
||||
let model =
|
||||
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert!(model.buffer_text(ctx).is_empty());
|
||||
assert!(!model.is_ephemeral());
|
||||
});
|
||||
|
||||
// Restore from a snapshot where the selection range is invalid.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| {
|
||||
let snapshot = EditorSnapshot {
|
||||
selections: vec1![CharOffset::from(5)..CharOffset::from(6)],
|
||||
buffer_text_runs: vec![TextRun::new(
|
||||
"foo".into(),
|
||||
TextStyle::new(),
|
||||
ByteOffset::from(0)..ByteOffset::from(3),
|
||||
)],
|
||||
};
|
||||
model.restore_from_snapshot(snapshot, ctx);
|
||||
},
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
// The selection range should fallback to (0, 0).
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "foo");
|
||||
assert_eq!(
|
||||
model.as_snapshot(ctx).selections,
|
||||
vec1![CharOffset::from(0)..CharOffset::from(0)]
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_redo() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app
|
||||
.add_model(|ctx| EditorModel::new("foo bar".into(), 0, None, ValidInputType::All, ctx));
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_change_selections(|model, ctx| {
|
||||
model
|
||||
.select_ranges_by_offset([CharOffset::from(2)..CharOffset::from(5)], ctx)
|
||||
.unwrap();
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.edit(
|
||||
ctx,
|
||||
Edits::new().with_update_buffer(
|
||||
PlainTextEditorViewAction::ReplaceBuffer,
|
||||
EditOrigin::UserInitiated,
|
||||
|model, ctx| {
|
||||
model.insert("z", None, ctx);
|
||||
},
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "fozar");
|
||||
assert_eq!(model.displayed_text(ctx), "fozar");
|
||||
assert_eq!(
|
||||
model.as_snapshot(ctx).selections,
|
||||
vec1![CharOffset::from(3)..CharOffset::from(3)]
|
||||
);
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.undo(ctx);
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "foo bar");
|
||||
assert_eq!(model.displayed_text(ctx), "foo bar");
|
||||
// TODO: we should consider making snapshot selections
|
||||
// more intuitive rather than switching between start / end
|
||||
// when snapshotting and restoring.
|
||||
assert_eq!(
|
||||
model.as_snapshot(ctx).selections,
|
||||
vec1![CharOffset::from(5)..CharOffset::from(2)]
|
||||
);
|
||||
});
|
||||
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.redo(ctx);
|
||||
});
|
||||
|
||||
model.read(&app, |model, ctx| {
|
||||
assert_eq!(model.buffer_text(ctx), "fozar");
|
||||
assert_eq!(model.displayed_text(ctx), "fozar");
|
||||
assert_eq!(
|
||||
model.as_snapshot(ctx).selections,
|
||||
vec1![CharOffset::from(3)..CharOffset::from(3)]
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
use std::{cmp::Ordering, mem, ops::Range};
|
||||
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use vec1::Vec1;
|
||||
use warpui::text::point::Point;
|
||||
use warpui::AppContext;
|
||||
|
||||
use super::{
|
||||
buffer::{Anchor, Buffer, LamportValue, ToBufferOffset, ToCharOffset, ToPoint},
|
||||
display_map::{DisplayMap, ToDisplayPoint},
|
||||
DisplayPoint, ReplicaId,
|
||||
};
|
||||
use crate::{
|
||||
editor::{
|
||||
soft_wrap::{ClampDirection, DisplayPointAndClampDirection},
|
||||
CursorColors, RangeExt,
|
||||
},
|
||||
ui_components::avatar::Avatar,
|
||||
};
|
||||
|
||||
/// This type encapsulates enough information about a selection to be able to
|
||||
/// draw it. Compared to the `Selection` type, the points are converted based on
|
||||
/// the `DisplayMap` to `DisplayPoint`s.
|
||||
pub struct DrawableSelection {
|
||||
pub range: Range<DisplayPoint>,
|
||||
pub clamp_direction: ClampDirection,
|
||||
pub replica_id: ReplicaId,
|
||||
}
|
||||
|
||||
/// This type holds additional information about how to draw a local peer's
|
||||
/// selections and cursors.
|
||||
pub struct LocalDrawableSelectionData {
|
||||
pub colors: CursorColors,
|
||||
pub should_draw_cursors: bool,
|
||||
}
|
||||
|
||||
/// This type holds additional information about how to draw a remote peer's
|
||||
/// selections and cursors.
|
||||
pub struct RemoteDrawableSelectionData {
|
||||
pub colors: CursorColors,
|
||||
pub should_draw_cursors: bool,
|
||||
pub avatar: Avatar,
|
||||
}
|
||||
|
||||
/// The minimal set of data to identify a selection
|
||||
/// in the system.
|
||||
///
|
||||
/// For local selections, we need more information
|
||||
/// so that we know how to extend them (see [`LocalSelection`]).
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
pub struct Selection {
|
||||
/// The start of the selection.
|
||||
/// The `start` is always <= the `end`,
|
||||
/// even if the selection is reversed.
|
||||
pub start: Anchor,
|
||||
pub end: Anchor,
|
||||
|
||||
/// Whether or not the selection is reversed.
|
||||
/// If true, that means that the `start` is
|
||||
/// where the "head" of the selection is (i.e.
|
||||
/// where the cursor should be drawn, where the
|
||||
/// selection should be extended from, etc.).
|
||||
pub reversed: bool,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn single_cursor(cursor: Anchor) -> Selection {
|
||||
Selection {
|
||||
start: cursor.clone(),
|
||||
end: cursor,
|
||||
reversed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn head(&self) -> &Anchor {
|
||||
if self.reversed {
|
||||
&self.start
|
||||
} else {
|
||||
&self.end
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tail(&self) -> &Anchor {
|
||||
if self.reversed {
|
||||
&self.end
|
||||
} else {
|
||||
&self.start
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range(&self, buffer: &Buffer) -> Range<Point> {
|
||||
let start = self.start.to_point(buffer).unwrap();
|
||||
let end = self.end.to_point(buffer).unwrap();
|
||||
if self.reversed {
|
||||
end..start
|
||||
} else {
|
||||
start..end
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_range(&self, map: &DisplayMap, app: &AppContext) -> Range<DisplayPoint> {
|
||||
let start = self.start.to_display_point(map, app).unwrap();
|
||||
let end = self.end.to_display_point(map, app).unwrap();
|
||||
if self.reversed {
|
||||
end..start
|
||||
} else {
|
||||
start..end
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_cursor_only(&self, buffer: &Buffer) -> bool {
|
||||
let start = self.start.to_point(buffer).unwrap();
|
||||
let end = self.end.to_point(buffer).unwrap();
|
||||
start == end
|
||||
}
|
||||
|
||||
/// Whether the selection encompasses the entire buffer.
|
||||
pub fn spans_entire_buffer(&self, buffer: &Buffer) -> bool {
|
||||
self.range(buffer).sorted() == (Point::zero(), buffer.max_point())
|
||||
}
|
||||
|
||||
pub fn to_offset(&self, buffer: &Buffer) -> Range<CharOffset> {
|
||||
let start = self
|
||||
.head()
|
||||
.to_char_offset(buffer)
|
||||
.expect("Should be able to convert selection head to offset.");
|
||||
let end = self
|
||||
.tail()
|
||||
.to_char_offset(buffer)
|
||||
.expect("Should be able to convert selection tail to offset.");
|
||||
start..end
|
||||
}
|
||||
|
||||
pub fn to_byte_offset(&self, buffer: &Buffer) -> Range<ByteOffset> {
|
||||
let start = self
|
||||
.head()
|
||||
.to_byte_offset(buffer)
|
||||
.expect("Should be able to convert selection head to offset.");
|
||||
let end = self
|
||||
.tail()
|
||||
.to_byte_offset(buffer)
|
||||
.expect("Should be able to convert selection tail to offset.");
|
||||
start..end
|
||||
}
|
||||
|
||||
pub fn start_to_point(&self, buffer: &Buffer) -> Point {
|
||||
self.start
|
||||
.to_point(buffer)
|
||||
.expect("Selection start should be valid Point")
|
||||
}
|
||||
|
||||
pub fn end_to_point(&self, buffer: &Buffer) -> Point {
|
||||
self.end
|
||||
.to_point(buffer)
|
||||
.expect("Selection end should be valid Point")
|
||||
}
|
||||
|
||||
/// Returns the selections display range iff it intersects the provided `range`.
|
||||
fn intersects_display_range(
|
||||
&self,
|
||||
range: &Range<DisplayPoint>,
|
||||
map: &DisplayMap,
|
||||
app: &AppContext,
|
||||
) -> Option<Range<DisplayPoint>> {
|
||||
let display_range = self.display_range(map, app);
|
||||
// TODO (suraj): this check is confusing because [`Selection::display_range`]
|
||||
// possibly returns a reversed range.
|
||||
let intersects = display_range.start <= range.end || display_range.end <= range.end;
|
||||
intersects.then_some(display_range)
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection made by the client itself.
|
||||
/// Since the editor is CRDT-compliant, we distinguish
|
||||
/// between local and remote (peer) selections.
|
||||
///
|
||||
/// A local selection contains enough information
|
||||
/// to identify the selection as well as _extend_ it.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct LocalSelection {
|
||||
pub selection: Selection,
|
||||
pub clamp_direction: ClampDirection,
|
||||
|
||||
// goal_*_column's represent the columns this selection really should take.
|
||||
// Sometimes, `start`/`end` might be less than their goal_*_column counterparts
|
||||
// so we store these to use them once enough characters are present. For empty
|
||||
// selections, goal_start_column == goal_end_column, similar to start == end.
|
||||
//
|
||||
// As a concrete example, suppose we had the following buffer:
|
||||
// "words
|
||||
// wo
|
||||
// words2"
|
||||
// If the existing selection is ds on the first line, then if we add a cursor below,
|
||||
// it becomes an empty selection at the end of the line. Specifically, the buffer now looks like:
|
||||
// "wor<ds>|
|
||||
// wo|
|
||||
// words2"
|
||||
// where <x> denotes a selection of string x. Now, if we add one more cursor below,
|
||||
// we want the buffer to look like the following
|
||||
// "wor<ds>|
|
||||
// wo|
|
||||
// wor<ds>|2"
|
||||
// That is, if we didn't keep track of the goal start _and_ end columns,
|
||||
// we would not be able to correctly make the selection on the last row.
|
||||
// Specifically, the empty selection on the middle row had the following information stored:
|
||||
// {goal_start_column: 3, goal_end_column: 5}, which is how we were able to make the selection
|
||||
// correctly on the last row. If we only kept a single goal_column, we would either lose
|
||||
// information about the start of the selection or the end of the selection.
|
||||
pub goal_start_column: Option<u32>,
|
||||
pub goal_end_column: Option<u32>,
|
||||
}
|
||||
|
||||
impl LocalSelection {
|
||||
pub fn start(&self) -> &Anchor {
|
||||
&self.selection.start
|
||||
}
|
||||
|
||||
pub fn end(&self) -> &Anchor {
|
||||
&self.selection.end
|
||||
}
|
||||
|
||||
pub fn head(&self) -> &Anchor {
|
||||
self.selection.head()
|
||||
}
|
||||
|
||||
pub fn tail(&self) -> &Anchor {
|
||||
self.selection.tail()
|
||||
}
|
||||
|
||||
pub fn reversed(&self) -> bool {
|
||||
self.selection.reversed
|
||||
}
|
||||
|
||||
pub fn range(&self, buffer: &Buffer) -> Range<Point> {
|
||||
self.selection.range(buffer)
|
||||
}
|
||||
|
||||
pub fn display_range(&self, map: &DisplayMap, app: &AppContext) -> Range<DisplayPoint> {
|
||||
self.selection.display_range(map, app)
|
||||
}
|
||||
|
||||
pub fn is_cursor_only(&self, buffer: &Buffer) -> bool {
|
||||
self.selection.is_cursor_only(buffer)
|
||||
}
|
||||
|
||||
/// Whether the selection encompasses the entire buffer.
|
||||
pub fn spans_entire_buffer(&self, buffer: &Buffer) -> bool {
|
||||
self.selection.spans_entire_buffer(buffer)
|
||||
}
|
||||
|
||||
pub fn to_offset(&self, buffer: &Buffer) -> Range<CharOffset> {
|
||||
self.selection.to_offset(buffer)
|
||||
}
|
||||
|
||||
pub fn to_byte_offset(&self, buffer: &Buffer) -> Range<ByteOffset> {
|
||||
self.selection.to_byte_offset(buffer)
|
||||
}
|
||||
|
||||
pub fn start_to_point(&self, buffer: &Buffer) -> Point {
|
||||
self.selection.start_to_point(buffer)
|
||||
}
|
||||
|
||||
pub fn end_to_point(&self, buffer: &Buffer) -> Point {
|
||||
self.selection.end_to_point(buffer)
|
||||
}
|
||||
|
||||
pub fn set_start(&mut self, new_start: Anchor) {
|
||||
self.selection.start = new_start;
|
||||
}
|
||||
|
||||
pub fn set_end(&mut self, new_end: Anchor) {
|
||||
self.selection.end = new_end;
|
||||
}
|
||||
|
||||
pub fn set_reversed(&mut self, reversed: bool) {
|
||||
self.selection.reversed = reversed;
|
||||
}
|
||||
|
||||
pub fn set_selection(&mut self, selection: Selection) {
|
||||
self.selection = selection;
|
||||
}
|
||||
|
||||
pub fn set_head(&mut self, buffer: &Buffer, cursor: Anchor) {
|
||||
if cursor
|
||||
.cmp(self.tail(), buffer)
|
||||
.expect("Anchors should be comparable")
|
||||
< Ordering::Equal
|
||||
{
|
||||
if !self.selection.reversed {
|
||||
mem::swap(&mut self.selection.start, &mut self.selection.end);
|
||||
self.selection.reversed = true;
|
||||
}
|
||||
self.selection.start = cursor;
|
||||
} else {
|
||||
if self.selection.reversed {
|
||||
mem::swap(&mut self.selection.start, &mut self.selection.end);
|
||||
self.selection.reversed = false;
|
||||
}
|
||||
self.selection.end = cursor;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_tail(&mut self, buffer: &Buffer, cursor: Anchor) {
|
||||
if cursor
|
||||
.cmp(self.head(), buffer)
|
||||
.expect("Anchors should be comparable")
|
||||
> Ordering::Equal
|
||||
{
|
||||
if !self.selection.reversed {
|
||||
mem::swap(&mut self.selection.start, &mut self.selection.end);
|
||||
self.selection.reversed = true;
|
||||
}
|
||||
self.selection.end = cursor;
|
||||
} else {
|
||||
if self.selection.reversed {
|
||||
mem::swap(&mut self.selection.start, &mut self.selection.end);
|
||||
self.selection.reversed = false;
|
||||
}
|
||||
self.selection.start = cursor;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test(start: Anchor, end: Anchor) -> Self {
|
||||
Self {
|
||||
selection: Selection {
|
||||
start,
|
||||
end,
|
||||
reversed: false,
|
||||
},
|
||||
clamp_direction: ClampDirection::Down,
|
||||
goal_end_column: None,
|
||||
goal_start_column: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ongoing selection made by the client itself.
|
||||
/// There is no concept of a pending remote selection.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct LocalPendingSelection {
|
||||
pub selection: LocalSelection,
|
||||
/// Determines how to extend selection based on starting selection.
|
||||
pub selection_mode: SelectionMode,
|
||||
/// Saved selection to refer back to when updating new selection.
|
||||
/// Used when extending selection from an already selected word.
|
||||
pub starting_selection: LocalSelection,
|
||||
pub is_single_selection: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub enum MarkedTextState {
|
||||
/// There is currently marked text in the editor.
|
||||
Active {
|
||||
/// The current selection given by the IME within the marked text.
|
||||
selected_range: Range<usize>,
|
||||
},
|
||||
/// There is no marked text in the editor.
|
||||
#[default]
|
||||
Inactive,
|
||||
}
|
||||
|
||||
/// The set of active and pending selections for this client.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct LocalSelections {
|
||||
/// A pending selection state, if any. This is used
|
||||
/// when a selection is actively being changed (e.g. mouse down).
|
||||
pub pending: Option<LocalPendingSelection>,
|
||||
|
||||
/// The set of established selections.
|
||||
/// There is always at least one selection.
|
||||
pub selections: Vec1<LocalSelection>,
|
||||
|
||||
/// Indicates whether or not the selections are marked text.
|
||||
pub marked_text_state: MarkedTextState,
|
||||
}
|
||||
|
||||
impl LocalSelections {
|
||||
pub fn new(selections: Vec1<LocalSelection>, marked_text_state: MarkedTextState) -> Self {
|
||||
Self {
|
||||
selections,
|
||||
pending: None,
|
||||
marked_text_state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn first(&self) -> &LocalSelection {
|
||||
self.selections.first()
|
||||
}
|
||||
|
||||
pub fn last(&self) -> &LocalSelection {
|
||||
self.selections.last()
|
||||
}
|
||||
|
||||
pub fn selection_insertion_index(&self, start: &Anchor, buffer: &Buffer) -> usize {
|
||||
selection_insertion_index(&self.selections, start, buffer)
|
||||
}
|
||||
|
||||
pub fn selections_intersecting_range<'a>(
|
||||
&'a self,
|
||||
range: Range<DisplayPoint>,
|
||||
map: &'a DisplayMap,
|
||||
app: &'a AppContext,
|
||||
) -> impl Iterator<Item = (&'a LocalSelection, Range<DisplayPoint>)> + 'a {
|
||||
let pending_selection = self.pending.as_ref().and_then(|s| {
|
||||
// If there's a single pending selection, it's already in the selections vector.
|
||||
if s.is_single_selection {
|
||||
return None;
|
||||
}
|
||||
let selection = &s.selection;
|
||||
s.selection
|
||||
.selection
|
||||
.intersects_display_range(&range, map, app)
|
||||
.map(|r| (selection, r))
|
||||
});
|
||||
|
||||
selections_intersecting_range(&self.selections, range, map, app).chain(pending_selection)
|
||||
}
|
||||
|
||||
pub fn drawable_selections_intersecting_range<'a>(
|
||||
&'a self,
|
||||
range: Range<DisplayPoint>,
|
||||
replica_id: ReplicaId,
|
||||
map: &'a DisplayMap,
|
||||
app: &'a AppContext,
|
||||
) -> impl Iterator<Item = DrawableSelection> + 'a {
|
||||
self.selections_intersecting_range(range, map, app).map(
|
||||
move |(selection, selection_range)| DrawableSelection {
|
||||
range: selection_range,
|
||||
clamp_direction: selection.clamp_direction,
|
||||
replica_id: replica_id.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn marked_text_state(&self) -> MarkedTextState {
|
||||
self.marked_text_state.clone()
|
||||
}
|
||||
|
||||
pub fn set_marked_text_state(&mut self, marked_text_state: MarkedTextState) {
|
||||
self.marked_text_state = marked_text_state;
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec1<LocalSelection>> for LocalSelections {
|
||||
fn from(selections: Vec1<LocalSelection>) -> Self {
|
||||
Self {
|
||||
selections,
|
||||
pending: None,
|
||||
marked_text_state: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines how to extend selection for text.
|
||||
/// Either extending by characters, lines or words at a time.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SelectionMode {
|
||||
Chars,
|
||||
Lines,
|
||||
Words,
|
||||
}
|
||||
|
||||
/// A selection range for a remote peer.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
pub struct RemoteSelection {
|
||||
pub selection: Selection,
|
||||
}
|
||||
|
||||
impl RemoteSelection {
|
||||
pub fn observed(&self, buffer: &Buffer) -> bool {
|
||||
self.selection.start.observed(buffer) && self.selection.end.observed(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LocalSelection> for RemoteSelection {
|
||||
fn from(local: LocalSelection) -> Self {
|
||||
Self {
|
||||
selection: local.selection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of selections for a remote client.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RemoteSelections {
|
||||
/// The selections themselves.
|
||||
pub selections: Vec1<RemoteSelection>,
|
||||
|
||||
/// The lamport timestamp of the update
|
||||
/// associated to this latest selection set.
|
||||
pub lamport: LamportValue,
|
||||
}
|
||||
|
||||
impl RemoteSelections {
|
||||
pub fn drawable_selections_intersecting_range<'a>(
|
||||
&'a self,
|
||||
range: Range<DisplayPoint>,
|
||||
replica_id: ReplicaId,
|
||||
map: &'a DisplayMap,
|
||||
app: &'a AppContext,
|
||||
) -> impl Iterator<Item = DrawableSelection> + 'a {
|
||||
selections_intersecting_range(&self.selections, range, map, app).map(
|
||||
move |(_, selection_range)| DrawableSelection {
|
||||
range: selection_range,
|
||||
clamp_direction: ClampDirection::default(),
|
||||
replica_id: replica_id.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SelectAction {
|
||||
Begin {
|
||||
position: DisplayPointAndClampDirection,
|
||||
add: bool,
|
||||
},
|
||||
Update {
|
||||
position: DisplayPoint,
|
||||
scroll_position: Vector2F,
|
||||
},
|
||||
Extend {
|
||||
position: DisplayPoint,
|
||||
scroll_position: Vector2F,
|
||||
},
|
||||
End,
|
||||
}
|
||||
|
||||
impl SelectAction {
|
||||
/// Create an action for beginning a selection - use this in tests that need to simulate
|
||||
/// clicking on an editor.
|
||||
#[cfg(test)]
|
||||
pub fn begin(point: DisplayPoint) -> Self {
|
||||
Self::Begin {
|
||||
position: DisplayPointAndClampDirection {
|
||||
point,
|
||||
clamp_direction: Default::default(),
|
||||
},
|
||||
add: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AsSelection {
|
||||
fn as_selection(&self) -> &Selection;
|
||||
fn as_mut_selection(&mut self) -> &mut Selection;
|
||||
}
|
||||
|
||||
impl AsSelection for LocalSelection {
|
||||
fn as_selection(&self) -> &Selection {
|
||||
&self.selection
|
||||
}
|
||||
|
||||
fn as_mut_selection(&mut self) -> &mut Selection {
|
||||
&mut self.selection
|
||||
}
|
||||
}
|
||||
|
||||
impl AsSelection for RemoteSelection {
|
||||
fn as_selection(&self) -> &Selection {
|
||||
&self.selection
|
||||
}
|
||||
|
||||
fn as_mut_selection(&mut self) -> &mut Selection {
|
||||
&mut self.selection
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_insertion_index<S: AsSelection>(
|
||||
selections: &Vec1<S>,
|
||||
start: &Anchor,
|
||||
buffer: &Buffer,
|
||||
) -> usize {
|
||||
match selections
|
||||
.binary_search_by(|probe| probe.as_selection().start.cmp(start, buffer).unwrap())
|
||||
{
|
||||
Ok(index) => index,
|
||||
Err(index) => {
|
||||
if index > 0
|
||||
&& selections[index - 1]
|
||||
.as_selection()
|
||||
.end
|
||||
.cmp(start, buffer)
|
||||
.unwrap()
|
||||
== Ordering::Greater
|
||||
{
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over the selections
|
||||
/// that intersect the given `range`.
|
||||
///
|
||||
/// Assumes that `selections` are sorted by their start point.
|
||||
fn selections_intersecting_range<'a, S: AsSelection>(
|
||||
selections: &'a Vec1<S>,
|
||||
range: Range<DisplayPoint>,
|
||||
map: &'a DisplayMap,
|
||||
app: &'a AppContext,
|
||||
) -> impl Iterator<Item = (&'a S, Range<DisplayPoint>)> + 'a {
|
||||
let start = map
|
||||
.anchor_before(range.start, super::Bias::Left, app)
|
||||
.unwrap();
|
||||
let start_index = selection_insertion_index(selections, &start, map.buffer(app));
|
||||
|
||||
selections[start_index..]
|
||||
.iter()
|
||||
.map_while(move |selection| {
|
||||
selection
|
||||
.as_selection()
|
||||
.intersects_display_range(&range, map, app)
|
||||
.map(|r| (selection, r))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use super::{DisplayMap, DisplayPoint};
|
||||
use anyhow::Result;
|
||||
use warpui::AppContext;
|
||||
|
||||
pub fn left(
|
||||
map: &DisplayMap,
|
||||
mut point: DisplayPoint,
|
||||
app: &AppContext,
|
||||
stop_at_line_start: bool,
|
||||
) -> Result<DisplayPoint> {
|
||||
if point.column() > 0 {
|
||||
*point.column_mut() -= 1;
|
||||
} else if !stop_at_line_start && point.row() > 0 {
|
||||
*point.row_mut() -= 1;
|
||||
*point.column_mut() = map.line_len(point.row(), app)?;
|
||||
}
|
||||
Ok(point)
|
||||
}
|
||||
|
||||
pub fn right(
|
||||
map: &DisplayMap,
|
||||
mut point: DisplayPoint,
|
||||
app: &AppContext,
|
||||
stop_at_line_end: bool,
|
||||
) -> Result<DisplayPoint> {
|
||||
let max_column = map.line_len(point.row(), app).unwrap();
|
||||
if point.column() < max_column {
|
||||
*point.column_mut() += 1;
|
||||
} else if !stop_at_line_end && point.row() < map.max_point(app).row() {
|
||||
*point.row_mut() += 1;
|
||||
*point.column_mut() = 0;
|
||||
}
|
||||
Ok(point)
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
use super::model::EditorModel;
|
||||
use super::{
|
||||
AutosuggestionLocation, AutosuggestionState, AutosuggestionType,
|
||||
BaselinePositionComputationMethod, Bias, DisplayPoint, DrawableSelection, ScrollState,
|
||||
ToBufferOffset, ToDisplayPoint,
|
||||
};
|
||||
use super::{ToCharOffset, ToPoint};
|
||||
#[cfg(feature = "voice_input")]
|
||||
use crate::editor::view::voice::VoiceInputState;
|
||||
|
||||
use crate::editor::soft_wrap::FrameLayouts;
|
||||
use crate::terminal::grid_size_util::grid_compute_baseline_position_fn;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
|
||||
use anyhow::Result;
|
||||
use core::f32;
|
||||
use instant::Instant;
|
||||
use rayon::prelude::*;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
cmp::{self},
|
||||
ops::Range,
|
||||
sync::Arc,
|
||||
};
|
||||
use warp_completer::completer::Description;
|
||||
use warpui::text::point::Point;
|
||||
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
use warpui::fonts::{FamilyId, Properties};
|
||||
use warpui::platform::LineStyle;
|
||||
use warpui::text_layout::{
|
||||
default_compute_baseline_position_fn, ClipConfig, ComputeBaselinePositionFn, StyleAndFont,
|
||||
TextAlignment, TextStyle, DEFAULT_TOP_BOTTOM_RATIO,
|
||||
};
|
||||
use warpui::EntityId;
|
||||
use warpui::{
|
||||
fonts::Cache as FontCache,
|
||||
text_layout::{self, LayoutCache},
|
||||
AppContext, ModelHandle,
|
||||
};
|
||||
|
||||
/// Ratio to calculate font size of cursor avatar.
|
||||
/// Found experimentally to scale the best proportionally with
|
||||
/// current font size and the avatar's size.
|
||||
pub const CURSOR_AVATAR_FONT_RATIO: f32 = 0.8;
|
||||
/// Offset to calculate size of cursor avatar.
|
||||
/// Found experimentally to look the best with current font size.
|
||||
pub const CURSOR_AVATAR_IMAGE_OFFSET: f32 = 4.;
|
||||
|
||||
/// Fudge factor to make the voice input icon slightly wider than it is tall.
|
||||
const VOICE_INPUT_ICON_IMAGE_OFFSET_X: f32 = 5.;
|
||||
|
||||
/// Minimum size of voice input icon.
|
||||
const MIN_VOICE_INPUT_ICON_SIZE: f32 = 16.;
|
||||
|
||||
/// Gap between voice input icon's botton and cursor's top.
|
||||
pub const VOICE_INPUT_ICON_CURSOR_GAP: f32 = 2.;
|
||||
|
||||
/// The amount of time the editor height must have remained shrunken
|
||||
/// before we actually shrink the height. This is to prevent jittering
|
||||
/// before an autosuggestion is computed on keypress, if the autosuggestion would wrap
|
||||
/// and cause the editor height to grow.
|
||||
const EDITOR_HEIGHT_SHRINK_DELAY: Duration = Duration::from_millis(25);
|
||||
|
||||
/// A read-only snapshot of the [`EditorView`] that is needed
|
||||
/// to render the [`EditorElement`].
|
||||
pub struct ViewSnapshot {
|
||||
pub view_id: EntityId,
|
||||
pub is_focused: bool,
|
||||
pub editor_model: ModelHandle<EditorModel>,
|
||||
|
||||
pub can_select: bool,
|
||||
|
||||
pub font_size: f32,
|
||||
pub font_family: FamilyId,
|
||||
pub placeholder_font_family: FamilyId,
|
||||
pub font_properties: Properties,
|
||||
pub line_height: f32,
|
||||
pub line_height_ratio: f32,
|
||||
pub em_width: f32,
|
||||
|
||||
pub autogrow: bool,
|
||||
pub is_empty: bool,
|
||||
|
||||
/// Map from prefix to placeholder text. The view can have multiple active prefixes that when
|
||||
/// exactly matched in the buffer cause additional placeholder ghost text to be displayed. The
|
||||
/// empty string prefix "" is the default placeholder (shown when buffer is empty).
|
||||
pub placeholder_texts: Arc<HashMap<String, String>>,
|
||||
|
||||
pub autosuggestion_state: Option<Arc<AutosuggestionState>>,
|
||||
pub command_xray: Option<Arc<Description>>,
|
||||
|
||||
pub cached_buffer_points: HashMap<Cow<'static, str>, Point>,
|
||||
|
||||
pub baseline_position_computation_method: BaselinePositionComputationMethod,
|
||||
|
||||
#[cfg(feature = "voice_input")]
|
||||
pub voice_input_state: VoiceInputState,
|
||||
|
||||
pub editor_height_shrink_delay: Arc<Mutex<EditorHeightShrinkDelay>>,
|
||||
}
|
||||
|
||||
/// A struct to hold the editor height before it was shrunk and the time it was first shrunk.
|
||||
/// This is used to delay shrinking the editor height until after it's remained shrunken for EDITOR_HEIGHT_SHRINK_DELAY.
|
||||
pub struct EditorHeightShrinkDelay {
|
||||
pub editor_height_before_shrink: f32,
|
||||
pub editor_height_shrink_start: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ViewSnapshot {
|
||||
/// Returns the editor height with the EDITOR_HEIGHT_SHRINK_DELAY applied, and updates internal state.
|
||||
pub fn get_editor_height_with_shrink_delay(&self, editor_height: f32) -> f32 {
|
||||
let mut editor_height_shrink_delay = self.editor_height_shrink_delay.lock();
|
||||
// If the editor_height stayed the same or grew, use editor_height and reset the shrink time.
|
||||
if editor_height >= editor_height_shrink_delay.editor_height_before_shrink {
|
||||
editor_height_shrink_delay.editor_height_before_shrink = editor_height;
|
||||
editor_height_shrink_delay.editor_height_shrink_start = None;
|
||||
return editor_height;
|
||||
}
|
||||
// From here we know the editor height shrank.
|
||||
// If there's no start time recorded, this is the first render where the editor height shrunk.
|
||||
// Record the current time but use the height before shrinking.
|
||||
let Some(editor_height_shrink_start) =
|
||||
editor_height_shrink_delay.editor_height_shrink_start
|
||||
else {
|
||||
editor_height_shrink_delay.editor_height_shrink_start = Some(Instant::now());
|
||||
return editor_height_shrink_delay.editor_height_before_shrink;
|
||||
};
|
||||
|
||||
// If the height has been shrunken for a while, use the latest shrunken height.
|
||||
// It is our new baseline.
|
||||
if editor_height_shrink_start.elapsed() > EDITOR_HEIGHT_SHRINK_DELAY {
|
||||
editor_height_shrink_delay.editor_height_before_shrink = editor_height;
|
||||
editor_height_shrink_delay.editor_height_shrink_start = None;
|
||||
editor_height
|
||||
} else {
|
||||
editor_height_shrink_delay.editor_height_before_shrink
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the time at which the editor height should be repainted with the shrunken editor height.
|
||||
pub fn get_editor_repaint_at(&self) -> Option<Instant> {
|
||||
self.editor_height_shrink_delay
|
||||
.lock()
|
||||
.editor_height_shrink_start
|
||||
.map(|start| start + EDITOR_HEIGHT_SHRINK_DELAY)
|
||||
}
|
||||
|
||||
pub fn baseline_position_fn(&self) -> ComputeBaselinePositionFn {
|
||||
// Copy the font family ID so that it can be moved into the closure.
|
||||
let font_family = self.font_family;
|
||||
match self.baseline_position_computation_method {
|
||||
BaselinePositionComputationMethod::Grid => {
|
||||
grid_compute_baseline_position_fn(font_family)
|
||||
}
|
||||
BaselinePositionComputationMethod::Default => default_compute_baseline_position_fn(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the placeholder text for the prefix that matches the current buffer content.
|
||||
pub fn matching_placeholder_text(&self, buffer_text: &str) -> Option<String> {
|
||||
// Find exact match - buffer content must equal the prefix exactly
|
||||
self.placeholder_texts
|
||||
.iter()
|
||||
.find(|(prefix, _)| buffer_text == prefix.as_str())
|
||||
.map(|(_, text)| text.clone())
|
||||
}
|
||||
|
||||
pub fn placeholder_text_exists(&self) -> bool {
|
||||
// Only consider the default placeholder (empty prefix) for "exists" checks.
|
||||
// This is used when laying out the input and determining input height.
|
||||
self.placeholder_texts.contains_key("")
|
||||
}
|
||||
|
||||
pub fn is_selecting(&self, app: &AppContext) -> bool {
|
||||
self.editor_model.as_ref(app).is_selecting(app)
|
||||
}
|
||||
|
||||
pub fn rightmost_point(&self, app: &AppContext) -> DisplayPoint {
|
||||
self.editor_model
|
||||
.as_ref(app)
|
||||
.display_map(app)
|
||||
.rightmost_point()
|
||||
}
|
||||
|
||||
pub fn max_point(&self, app: &AppContext) -> DisplayPoint {
|
||||
self.editor_model.as_ref(app).max_point(app)
|
||||
}
|
||||
|
||||
pub fn autosuggestion_location(&self) -> Option<AutosuggestionLocation> {
|
||||
self.autosuggestion_state
|
||||
.as_ref()
|
||||
.map(|state| state.location)
|
||||
}
|
||||
|
||||
pub fn active_autosuggestion(&self) -> bool {
|
||||
self.autosuggestion_state
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_active())
|
||||
}
|
||||
|
||||
pub fn active_next_command_suggestion(&self) -> bool {
|
||||
self.autosuggestion_state.as_ref().is_some_and(|s| {
|
||||
s.is_active()
|
||||
&& matches!(
|
||||
s.autosuggestion_type,
|
||||
AutosuggestionType::Command {
|
||||
was_intelligent_autosuggestion: true
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lays out the given ghosted text, which can be a placeholder or autosuggestion.
|
||||
fn layout_ghosted_text(
|
||||
&self,
|
||||
text: &str,
|
||||
size: &Vector2F,
|
||||
soft_wrap: bool,
|
||||
first_line_head_indent: Option<f32>,
|
||||
font_cache: &FontCache,
|
||||
layout_cache: &LayoutCache,
|
||||
) -> Vec<Arc<text_layout::Line>> {
|
||||
let font_size = self.font_size;
|
||||
|
||||
if soft_wrap {
|
||||
let text_frame = layout_cache.layout_text(
|
||||
text,
|
||||
LineStyle {
|
||||
font_size: self.font_size,
|
||||
line_height_ratio: self.line_height_ratio,
|
||||
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
&[(
|
||||
0..text.chars().count(),
|
||||
StyleAndFont::new(
|
||||
self.placeholder_font_family,
|
||||
self.font_properties,
|
||||
TextStyle::new(),
|
||||
),
|
||||
)],
|
||||
size.x(),
|
||||
f32::MAX,
|
||||
Default::default(),
|
||||
first_line_head_indent,
|
||||
&font_cache.text_layout_system(),
|
||||
);
|
||||
|
||||
// Return a vec of lines to be backward compatible with laying out placeholder
|
||||
// without soft_wrapping and autosuggestion. In the future we may want to
|
||||
// return a TextFrame here so we don't have to clone the text_frame lines
|
||||
text_frame
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| Arc::new(l.clone()))
|
||||
.collect()
|
||||
} else {
|
||||
text.lines()
|
||||
.map(|line| {
|
||||
layout_cache.layout_line(
|
||||
line,
|
||||
LineStyle {
|
||||
font_size,
|
||||
line_height_ratio: self.line_height_ratio,
|
||||
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
&[(
|
||||
0..line.chars().count(),
|
||||
StyleAndFont::new(
|
||||
self.placeholder_font_family,
|
||||
self.font_properties,
|
||||
TextStyle::new(),
|
||||
),
|
||||
)],
|
||||
f32::MAX,
|
||||
ClipConfig::default(),
|
||||
&font_cache.text_layout_system(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn layout_autosuggestion(
|
||||
&self,
|
||||
preceding_text_width: f32,
|
||||
font_cache: &FontCache,
|
||||
layout_cache: &LayoutCache,
|
||||
size: &Vector2F,
|
||||
soft_wrap: bool,
|
||||
) -> Vec<Arc<text_layout::Line>> {
|
||||
// The autosuggestion is laid out on the same line as editor text and then soft wraps.
|
||||
// This means the first line of the autosuggestion has preceding_text_width less width to use before it needs to wrap.
|
||||
self.autosuggestion_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_autosuggestion_text.as_ref())
|
||||
.map(|current_autosuggestion_text| {
|
||||
self.layout_ghosted_text(
|
||||
current_autosuggestion_text,
|
||||
size,
|
||||
soft_wrap,
|
||||
Some(preceding_text_width),
|
||||
font_cache,
|
||||
layout_cache,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Layout placeholder text with an optional indent for the first line.
|
||||
pub fn layout_placeholder_text(
|
||||
&self,
|
||||
placeholder_text: &str,
|
||||
first_line_indent: f32,
|
||||
font_cache: &FontCache,
|
||||
layout_cache: &LayoutCache,
|
||||
size: &Vector2F,
|
||||
soft_wrap: bool,
|
||||
) -> Vec<Arc<text_layout::Line>> {
|
||||
self.layout_ghosted_text(
|
||||
placeholder_text,
|
||||
size,
|
||||
soft_wrap,
|
||||
if first_line_indent > 0. {
|
||||
Some(first_line_indent)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
font_cache,
|
||||
layout_cache,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn clamp_scroll_left(&self, scroll_state: &ScrollState, max: f32) {
|
||||
let mut scroll_position = scroll_state.scroll_position.lock();
|
||||
let scroll_left = scroll_position.x();
|
||||
scroll_position.set_x(scroll_left.min(max));
|
||||
}
|
||||
|
||||
pub fn max_scroll_top(total_lines: f32, visible_lines: f32) -> f32 {
|
||||
(total_lines - visible_lines).max(0.)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn autoscroll_horizontally(
|
||||
&self,
|
||||
scroll_state: &ScrollState,
|
||||
start_row: u32,
|
||||
viewport_width: f32,
|
||||
scroll_width: f32,
|
||||
max_glyph_width: f32,
|
||||
layouts: Vec<&text_layout::Line>,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let map = self.editor_model.as_ref(app).display_map(app);
|
||||
|
||||
let mut target_left = f32::INFINITY;
|
||||
let mut target_right = 0.0_f32;
|
||||
for selection in self.editor_model.as_ref(app).selections(app) {
|
||||
let head = selection.head().to_display_point(map, app).unwrap();
|
||||
let start_column = head.column().saturating_sub(3);
|
||||
let end_column = cmp::min(map.line_len(head.row(), app).unwrap(), head.column() + 3);
|
||||
|
||||
if let Some(line) = layouts.get((head.row() - start_row) as usize) {
|
||||
target_left = target_left.min(line.x_for_index(start_column as usize));
|
||||
}
|
||||
if let Some(line) = layouts.get((head.row() - start_row) as usize) {
|
||||
target_right =
|
||||
target_right.max(line.x_for_index(end_column as usize) + max_glyph_width);
|
||||
}
|
||||
}
|
||||
target_right = target_right.min(scroll_width);
|
||||
|
||||
if target_right - target_left > viewport_width {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut scroll_position = scroll_state.scroll_position.lock();
|
||||
let scroll_left = scroll_position.x() * max_glyph_width;
|
||||
let scroll_right = scroll_left + viewport_width;
|
||||
|
||||
if target_left < scroll_left {
|
||||
scroll_position.set_x(target_left / max_glyph_width);
|
||||
} else if target_right > scroll_right {
|
||||
scroll_position.set_x((target_right - viewport_width) / max_glyph_width);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn autoscroll_vertically(
|
||||
&self,
|
||||
scroll_state: &ScrollState,
|
||||
total_lines: f32,
|
||||
visible_lines: f32,
|
||||
top_section_height_lines: f32,
|
||||
frame_layouts: &FrameLayouts,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let mut scroll_position = scroll_state.scroll_position.lock();
|
||||
let scroll_top = scroll_position.y();
|
||||
scroll_position.set_y(scroll_top.min(total_lines - visible_lines).max(0.));
|
||||
|
||||
let mut autoscroll_requested = scroll_state.autoscroll_requested.lock();
|
||||
if *autoscroll_requested {
|
||||
*autoscroll_requested = false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
let map = self.editor_model.as_ref(app).display_map(app);
|
||||
|
||||
let first_selection = self.editor_model.as_ref(app).first_selection(app);
|
||||
let first_selection_clamp_direction = first_selection.clamp_direction;
|
||||
let first_cursor = first_selection.head().to_display_point(map, app);
|
||||
|
||||
let first_cursor_top = match first_cursor {
|
||||
Ok(first_cursor) => {
|
||||
match frame_layouts
|
||||
.to_soft_wrap_point(first_cursor, first_selection_clamp_direction)
|
||||
{
|
||||
Some(point) => point.row() as f32 + top_section_height_lines,
|
||||
None => {
|
||||
log::error!("Failed to get softwrapped point from display point");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Error trying to turn selection into display point {err:?}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let last_selection = self.editor_model.as_ref(app).last_selection(app);
|
||||
let last_selection_clamp_direction = last_selection.clamp_direction;
|
||||
let last_cursor = last_selection.head().to_display_point(map, app);
|
||||
|
||||
let last_cursor_bottom = match last_cursor {
|
||||
Ok(last_cursor) => {
|
||||
match frame_layouts.to_soft_wrap_point(last_cursor, last_selection_clamp_direction)
|
||||
{
|
||||
Some(point) => point.row() as f32 + 1.0 + top_section_height_lines,
|
||||
None => {
|
||||
log::error!("Failed to get softwrapped point from display point");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Error trying to turn selection into display point {err:?}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let margin = ((visible_lines - (last_cursor_bottom - first_cursor_top)) / 2.0)
|
||||
.floor()
|
||||
.min(3.0);
|
||||
if margin < 0.0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let target_top = (first_cursor_top - margin).max(0.0);
|
||||
let target_bottom = last_cursor_bottom + margin;
|
||||
let start_row = scroll_position.y();
|
||||
let end_row = start_row + visible_lines;
|
||||
|
||||
if target_top < start_row {
|
||||
scroll_position.set_y(target_top.min(Self::max_scroll_top(total_lines, visible_lines)));
|
||||
} else if target_bottom >= end_row {
|
||||
scroll_position.set_y(
|
||||
(target_bottom - visible_lines)
|
||||
.min(Self::max_scroll_top(total_lines, visible_lines)),
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// If the range of rows passed does not exist, we just layout the rows that do exist
|
||||
/// within the provided range.
|
||||
pub fn layout_text_frames(
|
||||
&self,
|
||||
mut rows: Range<u32>,
|
||||
layout_cache: &LayoutCache,
|
||||
max_width: f32,
|
||||
left_notch_width_px: f32,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<Arc<text_layout::TextFrame>>> {
|
||||
let display_map = self.editor_model.as_ref(app).display_map(app);
|
||||
|
||||
rows.end = cmp::min(rows.end, display_map.max_point(app).row() + 1);
|
||||
if rows.start >= rows.end {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let family_id = self.font_family;
|
||||
let properties = self.font_properties;
|
||||
let font_size = self.font_size;
|
||||
|
||||
// Collect style information for lines
|
||||
let stylized_lines = rows
|
||||
.map(|row| {
|
||||
display_map
|
||||
.chars_with_styles_at(DisplayPoint::new(row, 0), app)
|
||||
.unwrap()
|
||||
// Stop when we hit the end of the row.
|
||||
.take_while(|c| c.char() != '\n')
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let font_cache = app.font_cache();
|
||||
|
||||
let line_height_ratio = self.line_height_ratio;
|
||||
|
||||
// Use rayon parallel iterators to efficiently lay out all of the lines.
|
||||
let text_layout_system = font_cache.text_layout_system();
|
||||
let layouts = stylized_lines
|
||||
.into_par_iter()
|
||||
.enumerate()
|
||||
.map(|(cur_line_idx, stylized_chars)| {
|
||||
let mut line = String::with_capacity(stylized_chars.len());
|
||||
let mut style_runs = Vec::with_capacity(stylized_chars.len());
|
||||
for (idx, stylized_char) in stylized_chars.into_iter().enumerate() {
|
||||
// Accumulate both style information and the characters to lay out.
|
||||
style_runs.push((
|
||||
idx..idx + 1,
|
||||
StyleAndFont::new(family_id, properties, stylized_char.style()),
|
||||
));
|
||||
line.push(stylized_char.char());
|
||||
}
|
||||
|
||||
layout_cache.layout_text(
|
||||
&line,
|
||||
LineStyle {
|
||||
font_size,
|
||||
line_height_ratio,
|
||||
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
&style_runs,
|
||||
max_width,
|
||||
f32::MAX,
|
||||
TextAlignment::default(),
|
||||
// Push over text by left notch width, if we're on the first line. Note notch width will be 0 if same line prompt is disabled.
|
||||
(cur_line_idx == 0).then_some(left_notch_width_px),
|
||||
&text_layout_system,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(layouts)
|
||||
}
|
||||
|
||||
pub fn line(&self, display_row: u32, app: &AppContext) -> Result<String> {
|
||||
self.editor_model
|
||||
.as_ref(app)
|
||||
.display_map(app)
|
||||
.line(display_row, app)
|
||||
}
|
||||
|
||||
pub fn all_drawable_selections_intersecting_range<'a>(
|
||||
&'a self,
|
||||
range: Range<DisplayPoint>,
|
||||
app: &'a AppContext,
|
||||
) -> impl 'a + Iterator<Item = DrawableSelection> {
|
||||
self.editor_model
|
||||
.as_ref(app)
|
||||
.all_drawable_selections_intersecting_range(range, app)
|
||||
}
|
||||
|
||||
// todo: dedup this with EditorView. it's really: given an editor model + row, get the line len
|
||||
pub fn line_len(&self, display_row: u32, app: &AppContext) -> Result<u32> {
|
||||
self.editor_model.as_ref(app).line_len(display_row, app)
|
||||
}
|
||||
|
||||
pub fn layout_line(
|
||||
&self,
|
||||
row: u32,
|
||||
text_layout_cache: &LayoutCache,
|
||||
app: &AppContext,
|
||||
) -> Result<Arc<text_layout::Line>> {
|
||||
let font_cache = app.font_cache();
|
||||
let line = self.line(row, app)?;
|
||||
|
||||
Ok(text_layout_cache.layout_line(
|
||||
&line,
|
||||
LineStyle {
|
||||
font_size: self.font_size,
|
||||
line_height_ratio: self.line_height_ratio,
|
||||
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
&[(
|
||||
0..self.line_len(row, app)? as usize,
|
||||
StyleAndFont::new(self.font_family, Default::default(), TextStyle::new()),
|
||||
)],
|
||||
f32::MAX,
|
||||
ClipConfig::default(),
|
||||
&font_cache.text_layout_system(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Finds the byte offset directly under the given point
|
||||
pub fn byte_offset_at_point(
|
||||
&self,
|
||||
point: &DisplayPoint,
|
||||
app: &AppContext,
|
||||
) -> Option<ByteOffset> {
|
||||
let model = self.editor_model.as_ref(app);
|
||||
let buffer = model.buffer(app);
|
||||
let map = model.display_map(app);
|
||||
point
|
||||
.to_buffer_point(map, Bias::Left, app)
|
||||
.and_then(|point| point.to_byte_offset(buffer))
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn display_point_at_byte_offset(
|
||||
&self,
|
||||
byte_offset: &ByteOffset,
|
||||
app: &AppContext,
|
||||
) -> Option<DisplayPoint> {
|
||||
let model = self.editor_model.as_ref(app);
|
||||
let buffer = model.buffer(app);
|
||||
let map = model.display_map(app);
|
||||
let char_offset = byte_offset.to_char_offset(buffer).ok()?;
|
||||
let point = char_offset.to_point(buffer).ok()?;
|
||||
point.to_display_point(map, app).ok()
|
||||
}
|
||||
|
||||
pub fn vim_visual_tails<'a>(
|
||||
&self,
|
||||
app: &'a AppContext,
|
||||
) -> impl Iterator<Item = DisplayPoint> + 'a {
|
||||
let editor_model = self.editor_model.as_ref(app);
|
||||
let map = editor_model.display_map(app);
|
||||
editor_model
|
||||
.vim_visual_tails()
|
||||
.iter()
|
||||
.filter_map(|anchor| anchor.to_display_point(map, app).ok())
|
||||
}
|
||||
|
||||
/// Returns the font size for a cursor avatar. Value is based on the snapshot's
|
||||
/// font size and an avatar-specific ratio.
|
||||
pub fn cursor_avatar_font_size(&self) -> f32 {
|
||||
self.font_size * CURSOR_AVATAR_FONT_RATIO
|
||||
}
|
||||
|
||||
/// Returns the size (diameter) for a cursor avatar. Value is based on the snapshot's
|
||||
/// font size and an avatar-specific offset.
|
||||
pub fn cursor_avatar_size(&self) -> f32 {
|
||||
self.font_size + CURSOR_AVATAR_IMAGE_OFFSET
|
||||
}
|
||||
|
||||
/// Returns the size to render the voice input icon at, scaled by the font size.
|
||||
pub fn voice_input_icon_size(&self) -> Vector2F {
|
||||
let scaled_size = (self.font_size + 1.).max(MIN_VOICE_INPUT_ICON_SIZE);
|
||||
vec2f(scaled_size + VOICE_INPUT_ICON_IMAGE_OFFSET_X, scaled_size)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,623 @@
|
||||
use super::{EditorAction, EditorView, VoiceTranscriptionOptions};
|
||||
use crate::ai::blocklist::InputType;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::EditorElement;
|
||||
use crate::server::server_api::TranscribeError;
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use crate::settings::{AISettings, VoiceInputToggleKey};
|
||||
use crate::themes::theme::Fill;
|
||||
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
|
||||
use crate::ui_components::icons;
|
||||
use crate::view_components::{FeaturePopup, NewFeaturePopupLabel};
|
||||
use crate::workspace::ToastStack;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use settings::Setting as _;
|
||||
use voice_input::{StartListeningError, VoiceInput, VoiceSessionResult};
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::AnsiColorIdentifier;
|
||||
use warpui::elements;
|
||||
use warpui::elements::{Container, CornerRadius, Icon, Radius};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::ui_components::button::ButtonTooltipPosition;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::ViewHandle;
|
||||
use warpui::{AppContext, Element, SingletonEntity, ViewContext};
|
||||
|
||||
use super::VoiceTranscriber;
|
||||
|
||||
const MICROPHONE_ACCESS_ERROR_ID: &str = "MICROPHONE_ACCESS_ERROR";
|
||||
const NUM_TIMES_TO_SHOW_VOICE_NEW_FEATURE_POPUP: usize = 4;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(super) enum VoiceInputState {
|
||||
#[default]
|
||||
Stopped,
|
||||
|
||||
/// We are listening for voice input. This is happening in the singleton voice transcriber.
|
||||
Listening,
|
||||
|
||||
/// We are done listening and are transcribing voice input.
|
||||
Transcribing {
|
||||
/// The handle to the future that is spawned for voice input while transcribing is taking place.
|
||||
handle: SpawnedFutureHandle,
|
||||
},
|
||||
}
|
||||
|
||||
impl VoiceInputState {
|
||||
pub(super) fn is_active(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
VoiceInputState::Listening | VoiceInputState::Transcribing { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn icon(&self) -> Option<icons::Icon> {
|
||||
match self {
|
||||
VoiceInputState::Listening => Some(icons::Icon::Microphone),
|
||||
VoiceInputState::Transcribing { .. } => Some(icons::Icon::DotsHorizontal),
|
||||
VoiceInputState::Stopped => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorView {
|
||||
pub(super) fn is_voice_input_active(&self) -> bool {
|
||||
self.voice_input_state.is_active()
|
||||
}
|
||||
|
||||
pub(super) fn create_voice_new_feature_popup(
|
||||
ctx: &mut ViewContext<EditorView>,
|
||||
) -> ViewHandle<FeaturePopup> {
|
||||
let voice_new_feature_popup = ctx.add_typed_action_view(|_| {
|
||||
FeaturePopup::new_feature(NewFeaturePopupLabel::FromString(
|
||||
"Try Voice Input".to_string(),
|
||||
))
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&voice_new_feature_popup, |_me, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
crate::view_components::NewFeaturePopupEvent::Dismissed
|
||||
) {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
warp_core::report_if_error!(settings
|
||||
.dismissed_voice_input_new_feature_popup
|
||||
.set_value(true, ctx));
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
voice_new_feature_popup
|
||||
}
|
||||
|
||||
pub(super) fn should_show_voice_new_feature_popup(&self, app: &AppContext) -> bool {
|
||||
let ai_settings = AISettings::handle(app).as_ref(app);
|
||||
let voice_input = voice_input::VoiceInput::handle(app).as_ref(app);
|
||||
|
||||
let num_times_entered_agent_mode = *ai_settings.entered_agent_mode_num_times;
|
||||
let manually_dismissed_voice_input_new_feature_popup =
|
||||
*ai_settings.dismissed_voice_input_new_feature_popup;
|
||||
let explicitly_interacted_with_voice = *ai_settings.explicitly_interacted_with_voice;
|
||||
|
||||
num_times_entered_agent_mode <= NUM_TIMES_TO_SHOW_VOICE_NEW_FEATURE_POPUP
|
||||
&& !manually_dismissed_voice_input_new_feature_popup
|
||||
&& !explicitly_interacted_with_voice
|
||||
&& !voice_input.should_suppress_new_feature_popup
|
||||
}
|
||||
|
||||
/// Configures an [`EditorElement`] for the current voice input state.
|
||||
pub(super) fn configure_editor_element_voice(
|
||||
&self,
|
||||
editor_element: EditorElement,
|
||||
appearance: &Appearance,
|
||||
) -> EditorElement {
|
||||
if let Some(icon) = self.voice_input_state.icon() {
|
||||
editor_element.with_voice_input_cursor_icon(
|
||||
Container::new(
|
||||
Icon::new(icon.into(), internal_colors::neutral_1(appearance.theme())).finish(),
|
||||
)
|
||||
.with_background(Fill::Solid(appearance.theme().accent().into()))
|
||||
.with_uniform_padding(4.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish(),
|
||||
)
|
||||
} else {
|
||||
editor_element
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_voice_transcription_options(
|
||||
&mut self,
|
||||
options: VoiceTranscriptionOptions,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !UserWorkspaces::handle(ctx).as_ref(ctx).is_voice_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
log::debug!("update_voice_transcription_options: {options:?}");
|
||||
self.voice_transcription_options = options;
|
||||
if !self.voice_transcription_options.is_enabled() {
|
||||
self.stop_voice_input(true, ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn voice_options(ctx: &mut ViewContext<Self>) -> VoiceTranscriptionOptions {
|
||||
let ai_settings_handle = AISettings::handle(ctx);
|
||||
if ai_settings_handle.as_ref(ctx).is_voice_input_enabled(ctx) {
|
||||
VoiceTranscriptionOptions::Enabled { show_button: false }
|
||||
} else {
|
||||
VoiceTranscriptionOptions::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stop_voice_input(
|
||||
&mut self,
|
||||
cancel_transcription: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !UserWorkspaces::handle(ctx).as_ref(ctx).is_voice_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let voice_input = voice_input::VoiceInput::handle(ctx);
|
||||
if voice_input.as_ref(ctx).is_listening() {
|
||||
log::debug!("Stopping voice input, cancelling transcription: {cancel_transcription}");
|
||||
voice_input.update(ctx, |voice_input, ctx| {
|
||||
if cancel_transcription {
|
||||
voice_input.abort_listening();
|
||||
} else if let Err(e) = voice_input.stop_listening(ctx) {
|
||||
log::error!("Failed to stop voice input: {e:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
if cancel_transcription {
|
||||
self.stop_transcribing_voice_input(ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn stop_transcribing_voice_input(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
VoiceInput::handle(ctx).update(ctx, |voice, _| voice.set_transcribing_active(false));
|
||||
if let VoiceInputState::Transcribing { handle, .. } = &self.voice_input_state {
|
||||
log::debug!("Aborting voice input transcription");
|
||||
handle.abort();
|
||||
}
|
||||
self.set_voice_input_state(VoiceInputState::Stopped, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn voice_error_toast(&mut self, message: &str, ctx: &mut ViewContext<Self>) {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = crate::view_components::DismissibleToast::error(message.to_string());
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toggle_voice_input(
|
||||
&mut self,
|
||||
source: &voice_input::VoiceInputToggledFrom,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
if !UserWorkspaces::handle(ctx).as_ref(ctx).is_voice_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
Self::voice_options(ctx),
|
||||
VoiceTranscriptionOptions::Enabled { .. }
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Toggling voice input from {:?} for current state: {:?}",
|
||||
source,
|
||||
self.voice_input_state
|
||||
);
|
||||
|
||||
match *source {
|
||||
voice_input::VoiceInputToggledFrom::Button => {
|
||||
// Allow button clicks to focus and start/stop voice input.
|
||||
ctx.focus_self();
|
||||
}
|
||||
voice_input::VoiceInputToggledFrom::Key { state } => {
|
||||
// For keypresses, only start voice input if the editor is focused.
|
||||
// Note, stopping voice input via keypress is handled in a global handler.
|
||||
if !self.focused {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the keypress is not valid in the current state, we ignore it.
|
||||
match &self.voice_input_state {
|
||||
// For example, the user could press Fn in a different app, then switch focus
|
||||
// to Warp and let it go - we should NOT activate voice input in this case.
|
||||
VoiceInputState::Stopped => {
|
||||
if matches!(state, warpui::event::KeyState::Released) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Note that in reality, this case is unreachable because we stop voice input
|
||||
// if the user is not focused on Warp (since we lose the ability to listen to modifier
|
||||
// key events). Thus, the user cannot enter a state where we're listening for voice input
|
||||
// but the key is not held already.
|
||||
VoiceInputState::Listening => {
|
||||
if matches!(state, warpui::event::KeyState::Pressed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match &self.voice_input_state {
|
||||
VoiceInputState::Stopped => {
|
||||
if !self.voice_transcription_options.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !crate::ai::AIRequestUsageModel::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.can_request_voice()
|
||||
{
|
||||
self.voice_error_toast(super::VOICE_LIMIT_HIT_TOAST_TEXT, ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
// We allow toggling voice input from a button click even if the editor is not focused.
|
||||
if self.focused || matches!(*source, voice_input::VoiceInputToggledFrom::Button) {
|
||||
// Try to start voice input and get the session
|
||||
let session_result = voice_input::VoiceInput::handle(ctx)
|
||||
.update(ctx, |voice_input, ctx| {
|
||||
voice_input.start_listening(ctx, source.clone())
|
||||
});
|
||||
|
||||
let session = match session_result {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
match e {
|
||||
StartListeningError::AccessDenied => {
|
||||
Self::show_microphone_access_toast(ctx);
|
||||
}
|
||||
_ => {
|
||||
log::error!("Failed to start voice input: {e:?}");
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Immediately transition to Listening state
|
||||
self.set_voice_input_state(VoiceInputState::Listening, ctx);
|
||||
|
||||
// Send telemetry for start
|
||||
let is_udi_enabled = crate::settings::InputSettings::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.is_universal_developer_input_enabled(ctx);
|
||||
let current_input_mode = if self.is_ai_input {
|
||||
InputType::AI
|
||||
} else {
|
||||
InputType::Shell
|
||||
};
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::VoiceInputUsed {
|
||||
action: "start".to_string(),
|
||||
session_duration_ms: None,
|
||||
is_udi_enabled,
|
||||
current_input_mode,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
// Spawn future to await the session result
|
||||
ctx.spawn(
|
||||
async move { session.await_result().await },
|
||||
Self::handle_voice_session_result,
|
||||
);
|
||||
|
||||
if matches!(*source, voice_input::VoiceInputToggledFrom::Button) {
|
||||
// If the user hasn't explicitly interacted with voice yet, show first-time toast.
|
||||
let window_id = ctx.window_id();
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Some(toggle_key) = settings.maybe_setup_first_time_voice(ctx) {
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = crate::view_components::DismissibleToast::success(
|
||||
format!(
|
||||
"Voice input is enabled. You can also press and hold the `{}` key to activate voice input (configure in Settings > AI > Voice)",
|
||||
toggle_key.display_name()
|
||||
)
|
||||
.to_string(),
|
||||
);
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
VoiceInputState::Listening => {
|
||||
self.stop_voice_input(false, ctx);
|
||||
}
|
||||
VoiceInputState::Transcribing { .. } => {
|
||||
// Do nothing, we're already transcribing. We don't allow switching states while this is happening.
|
||||
// TODO(zach): We may want to show some sort of progress indicator when in this state.
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
false
|
||||
}
|
||||
|
||||
fn show_microphone_access_toast(ctx: &mut ViewContext<Self>) {
|
||||
let active_window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, move |toast_stack, ctx| {
|
||||
let mut toast = crate::view_components::DismissibleToast::error(String::from(
|
||||
"Failed to start voice input (you may need to enable Microphone access)",
|
||||
));
|
||||
// Set an id so the toast is shown at most once.
|
||||
toast = toast.with_object_id(MICROPHONE_ACCESS_ERROR_ID.to_string());
|
||||
toast_stack.add_ephemeral_toast(toast, active_window_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_voice_input_state(
|
||||
&mut self,
|
||||
voice_input_state: VoiceInputState,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let was_active = self.is_voice_input_active();
|
||||
let is_listening = matches!(voice_input_state, VoiceInputState::Listening);
|
||||
let is_transcribing = matches!(voice_input_state, VoiceInputState::Transcribing { .. });
|
||||
|
||||
let will_be_active = matches!(
|
||||
voice_input_state,
|
||||
VoiceInputState::Listening | VoiceInputState::Transcribing { .. }
|
||||
);
|
||||
|
||||
if !was_active && will_be_active {
|
||||
// Lock before marking active, so set_interaction_state applies normally.
|
||||
self.interaction_state_before_voice = Some(self.interaction_state(ctx));
|
||||
self.set_interaction_state(super::InteractionState::Selectable, ctx);
|
||||
self.voice_input_state = voice_input_state;
|
||||
} else if was_active && !will_be_active {
|
||||
// Mark inactive before restoring, so set_interaction_state applies normally.
|
||||
self.voice_input_state = voice_input_state;
|
||||
if let Some(state) = self.interaction_state_before_voice.take() {
|
||||
self.set_interaction_state(state, ctx);
|
||||
}
|
||||
} else {
|
||||
// Transition between active states (e.g. Listening → Transcribing).
|
||||
self.voice_input_state = voice_input_state;
|
||||
}
|
||||
|
||||
ctx.emit(super::Event::VoiceStateUpdated {
|
||||
is_listening,
|
||||
is_transcribing,
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles the result of a voice recording session.
|
||||
/// This is called when the VoiceSession future resolves.
|
||||
pub(super) fn handle_voice_session_result(
|
||||
&mut self,
|
||||
result: VoiceSessionResult,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !UserWorkspaces::handle(ctx).as_ref(ctx).is_voice_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_udi_enabled = crate::settings::InputSettings::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.is_universal_developer_input_enabled(ctx);
|
||||
let current_input_mode = if self.is_ai_input {
|
||||
InputType::AI
|
||||
} else {
|
||||
InputType::Shell
|
||||
};
|
||||
|
||||
match result {
|
||||
VoiceSessionResult::Audio {
|
||||
wav_base64,
|
||||
session_duration_ms,
|
||||
} => {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::VoiceInputUsed {
|
||||
action: "stop".to_string(),
|
||||
session_duration_ms: Some(session_duration_ms),
|
||||
is_udi_enabled,
|
||||
current_input_mode,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
// Start transcription
|
||||
let voice_transcriber = VoiceTranscriber::handle(ctx).as_ref(ctx);
|
||||
if let Some(transcriber) = voice_transcriber.transcriber() {
|
||||
let transcriber = transcriber.clone();
|
||||
|
||||
VoiceInput::handle(ctx).update(ctx, |voice, _| {
|
||||
voice.set_transcribing_active(true);
|
||||
});
|
||||
|
||||
self.set_voice_input_state(
|
||||
VoiceInputState::Transcribing {
|
||||
handle: ctx.spawn(
|
||||
async move { transcriber.transcribe(wav_base64).await },
|
||||
Self::apply_transcribed_voice_input,
|
||||
),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
self.set_voice_input_state(VoiceInputState::Stopped, ctx);
|
||||
}
|
||||
}
|
||||
VoiceSessionResult::Aborted {
|
||||
session_duration_ms,
|
||||
} => {
|
||||
log::info!("Aborted listening for voice input");
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::VoiceInputUsed {
|
||||
action: "cancel".to_string(),
|
||||
session_duration_ms,
|
||||
is_udi_enabled,
|
||||
current_input_mode,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
self.set_voice_input_state(VoiceInputState::Stopped, ctx);
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn apply_transcribed_voice_input(
|
||||
&mut self,
|
||||
result: Result<String, TranscribeError>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !self.voice_transcription_options.is_enabled() {
|
||||
self.stop_transcribing_voice_input(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
self.stop_transcribing_voice_input(ctx);
|
||||
match result {
|
||||
Ok(transcribe_response) => {
|
||||
log::debug!("Transcribed voice input: {transcribe_response:?}");
|
||||
self.user_insert(&transcribe_response, ctx);
|
||||
}
|
||||
Err(e) => match e {
|
||||
TranscribeError::QuotaLimit => {
|
||||
self.voice_error_toast(super::VOICE_LIMIT_HIT_TOAST_TEXT, ctx)
|
||||
}
|
||||
_ => {
|
||||
log::error!("Failed to transcribe voice input: {e:?}");
|
||||
self.voice_error_toast(super::VOICE_ERROR_TOAST_TEXT, ctx)
|
||||
}
|
||||
},
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_voice_transcription_button_tooltip(
|
||||
&self,
|
||||
appearance: &crate::appearance::Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn FnOnce() -> Box<dyn Element>> {
|
||||
let tooltip_background = appearance.theme().surface_1().into_solid();
|
||||
let tooltip_text_color = appearance
|
||||
.theme()
|
||||
.main_text_color(tooltip_background.into())
|
||||
.into_solid();
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
|
||||
let microphone_access_state = app.microphone_access_state();
|
||||
let mic_access_denied = matches!(
|
||||
microphone_access_state,
|
||||
warpui::platform::MicrophoneAccessState::Restricted
|
||||
| warpui::platform::MicrophoneAccessState::Denied
|
||||
);
|
||||
|
||||
let modifier_key = AISettings::handle(app).as_ref(app).voice_input_toggle_key;
|
||||
let tooltip_text = if mic_access_denied {
|
||||
"Voice transcription is disabled because Microphone access was not granted.".to_string()
|
||||
} else if modifier_key == VoiceInputToggleKey::None {
|
||||
"Voice transcription".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Voice transcription (hold `{}` key)",
|
||||
modifier_key.display_name().to_lowercase()
|
||||
)
|
||||
};
|
||||
|
||||
Box::new(move || {
|
||||
let tool_tip_style = UiComponentStyles {
|
||||
background: Some(elements::Fill::Solid(tooltip_background)),
|
||||
font_color: Some(tooltip_text_color),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ui_builder
|
||||
.tool_tip(tooltip_text)
|
||||
.with_style(tool_tip_style)
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn render_voice_transcription_button(
|
||||
&self,
|
||||
icon_size: f32,
|
||||
appearance: &crate::appearance::Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut button = if voice_input::VoiceInput::handle(app)
|
||||
.as_ref(app)
|
||||
.is_listening()
|
||||
{
|
||||
icon_button_with_color(
|
||||
appearance,
|
||||
icons::Icon::Stop,
|
||||
true,
|
||||
self.voice_transcription_button_mouse_handle.clone(),
|
||||
Fill::Solid(
|
||||
AnsiColorIdentifier::Red
|
||||
.to_ansi_color(&appearance.theme().terminal_colors().normal)
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
let is_transcribing =
|
||||
matches!(self.voice_input_state, VoiceInputState::Transcribing { .. });
|
||||
icon_button(
|
||||
appearance,
|
||||
icons::Icon::Microphone,
|
||||
is_transcribing,
|
||||
self.voice_transcription_button_mouse_handle.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
button = button.with_style(UiComponentStyles {
|
||||
width: Some(icon_size),
|
||||
height: Some(icon_size),
|
||||
padding: Some(Coords::uniform(icon_size / 10.)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if !self.should_show_voice_new_feature_popup(app) {
|
||||
button = button
|
||||
.with_tooltip_position(ButtonTooltipPosition::Above)
|
||||
.with_tooltip(self.render_voice_transcription_button_tooltip(appearance, app));
|
||||
}
|
||||
|
||||
if matches!(self.voice_input_state, VoiceInputState::Transcribing { .. }) {
|
||||
button = button.disabled();
|
||||
}
|
||||
|
||||
warpui::elements::SavePosition::new(
|
||||
button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorAction::ToggleVoiceInput(
|
||||
voice_input::VoiceInputToggledFrom::Button,
|
||||
));
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
"voice_transcription_button",
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user