2231 lines
88 KiB
Rust
2231 lines
88 KiB
Rust
use base64::{prelude::BASE64_STANDARD, Engine as _};
|
|
use std::{any::Any, borrow::Cow, collections::HashMap, ops::Range, time::Duration};
|
|
|
|
use itertools::Itertools;
|
|
use lazy_static::lazy_static;
|
|
use markdown_parser::FormattedText;
|
|
use mermaid_to_svg::MermaidTheme;
|
|
use num_traits::SaturatingSub;
|
|
use regex::Regex;
|
|
use url::Url;
|
|
use vec1::{vec1, Vec1};
|
|
use galaxyui::{
|
|
accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole},
|
|
clipboard::ClipboardContent,
|
|
AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, WindowId,
|
|
};
|
|
|
|
use crate::{
|
|
cloud_object::model::persistence::{CloudModel, CloudModelEvent},
|
|
debounce::debounce,
|
|
editor::InteractionState,
|
|
notebooks::telemetry::BlockInfo,
|
|
};
|
|
use crate::{
|
|
notebooks::editor::interaction_state_model::InteractionStateModelEvent,
|
|
terminal::ShellLaunchData,
|
|
};
|
|
use string_offset::CharOffset;
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxy_core::semantic_selection::SemanticSelection;
|
|
use galaxy_editor::{
|
|
content::{buffer::ShouldAutoscroll, selection_model::BufferSelectionModel},
|
|
model::BufferUpdateWrapper,
|
|
render::model::{BlockItem, StyleUpdateAction},
|
|
};
|
|
use galaxy_editor::{
|
|
content::{
|
|
buffer::{
|
|
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction,
|
|
EditOrigin, SelectionOffsets,
|
|
},
|
|
text::{
|
|
BlockHeaderSize, BlockType, BufferBlockItem, BufferBlockStyle, BufferTextStyle,
|
|
CodeBlockType, IndentBehavior, IndentUnit, TextStyles, TextStylesWithMetadata,
|
|
},
|
|
},
|
|
model::{CoreEditorModel, RichTextEditorModel},
|
|
render::model::{AutoScrollMode, RenderEvent, RenderState, RichTextStyles},
|
|
search::Searcher,
|
|
selection::{SelectionMode, SelectionModel, TextDirection, TextUnit},
|
|
};
|
|
use galaxyui::elements::ListIndentLevel;
|
|
|
|
use super::{
|
|
super::telemetry::SelectionMode as TelemetrySelectionMode, embedding_model::NotebookEmbed,
|
|
interaction_state_model::InteractionStateModel, notebook_command::NotebookCommand,
|
|
NotebookWorkflow,
|
|
};
|
|
|
|
const DEBOUNCED_RESIZE_PERIOD: Duration = Duration::from_millis(5);
|
|
|
|
lazy_static! {
|
|
// Specifically match ASCII digits using [[:digit:]], rather than \d, which is Unicode-aware.
|
|
static ref NUMBERED_LIST_SHORTCUT_PREFIX: Regex = Regex::new(r"^([[:digit:]]+)\. $").expect("Markdown shortcut regex should be valid");
|
|
// Note this is slightly different from markdown syntax, which supports interleaving asterisk, dash and underline.
|
|
// For markdown shortcut specifically, this feels more closely mapped to user intention.
|
|
static ref HORIZONTAL_RULE_SHORTCUT_PREFIX: Regex = Regex::new(r"^(\*\*\*+|---+|___+) $").expect("Markdown shortcut regex should be valid");
|
|
|
|
static ref ITALIC_INLINE_REGEX: Regex =
|
|
Regex::new(r"\*([^\*]+?)\*$").expect("Markdown shortcut regex should be valid");
|
|
static ref BOLD_INLINE_REGEX: Regex =
|
|
Regex::new(r"\*\*([^\*]+?)\*\*$").expect("Markdown shortcut regex should be valid");
|
|
static ref INLINE_CODE_INLINE_REGEX: Regex =
|
|
Regex::new(r"`([^`]+?)`$").expect("Markdown shortcut regex should be valid");
|
|
static ref BOLD_ITALIC_INLINE_REGEX: Regex =
|
|
Regex::new(r"\*\*\*([^\*]+?)\*\*\*$").expect("Markdown shortcut regex should be valid");
|
|
// Underscore-delimited variants of italic, bold, and bold-italic inline markdown.
|
|
// Matches `_text_`, `__text__`, and `___text___` respectively.
|
|
static ref ITALIC_UNDERSCORE_INLINE_REGEX: Regex =
|
|
Regex::new(r"_([^_]+?)_$").expect("Markdown shortcut regex should be valid");
|
|
static ref BOLD_UNDERSCORE_INLINE_REGEX: Regex =
|
|
Regex::new(r"__([^_]+?)__$").expect("Markdown shortcut regex should be valid");
|
|
static ref BOLD_ITALIC_UNDERSCORE_INLINE_REGEX: Regex =
|
|
Regex::new(r"___([^_]+?)___$").expect("Markdown shortcut regex should be valid");
|
|
static ref STRIKETHROUGH_REGEX: Regex =
|
|
Regex::new(r"~~([^~]+?)~~$").expect("Markdown shortcut regex should be valid");
|
|
static ref LINK_INLINE_REGEX: Regex =
|
|
Regex::new(r"\[(.+?)\]\((.+?)\)$").expect("Markdown shortcut regex should be valid");
|
|
static ref INCOMPLETE_TASKLIST_INLINE_REGEX: Regex =
|
|
Regex::new(r"^\[ ?\] $").expect("Markdown shortcut regex should be valid");
|
|
static ref COMPLETE_TASKLIST_INLINE_REGEX: Regex =
|
|
Regex::new(r"^\[[xX]\] $").expect("Markdown shortcut regex should be valid");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "model_tests.rs"]
|
|
mod tests;
|
|
|
|
/// Model for managing the state of the editor.
|
|
pub struct NotebooksEditorModel {
|
|
pub(super) render_state: ModelHandle<RenderState>,
|
|
content: ModelHandle<Buffer>,
|
|
selection_model: ModelHandle<BufferSelectionModel>,
|
|
selection: ModelHandle<SelectionModel>,
|
|
child_models: ChildModels,
|
|
active_text_style: TextStyles,
|
|
active_block_type: BlockType,
|
|
interaction_state: ModelHandle<InteractionStateModel>,
|
|
rte_window_id: Option<WindowId>, // Needed for rendering any subviews from the model directly. Set when pane is attached.
|
|
/// Channel for debounced viewport resizing.
|
|
resize_tx: async_channel::Sender<()>,
|
|
/// Context used to generate clickable file path links for notebooks.
|
|
file_link_resolution_context: Option<FileLinkResolutionContext>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
/// Context used to generate clickable file path links for notebooks.
|
|
pub struct FileLinkResolutionContext {
|
|
/// The working directory of the session that the editor is associated with.
|
|
pub working_directory: String,
|
|
/// The shell launch data of the session that the editor is associated with.
|
|
pub shell_launch_data: Option<ShellLaunchData>,
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
enum InlineStyleAction {
|
|
Insert {
|
|
matched_length: usize,
|
|
text: String,
|
|
style: TextStyles,
|
|
override_text_style: Option<TextStyles>,
|
|
},
|
|
Link {
|
|
matched_length: usize,
|
|
tag: String,
|
|
url: String,
|
|
},
|
|
None,
|
|
}
|
|
|
|
fn mermaid_image_html(svg: &[u8]) -> String {
|
|
format!(
|
|
"<img src=\"data:image/svg+xml;base64,{}\" alt=\"Mermaid diagram\" />",
|
|
BASE64_STANDARD.encode(svg)
|
|
)
|
|
}
|
|
|
|
fn render_mermaid_clipboard_html(source: &str) -> Option<String> {
|
|
let svg = mermaid_to_svg::render_mermaid_to_svg(source, Some(&MermaidTheme::light()))
|
|
.ok()?
|
|
.into_bytes();
|
|
Some(mermaid_image_html(&svg))
|
|
}
|
|
|
|
impl NotebooksEditorModel {
|
|
fn editable_markdown_mermaid_enabled() -> bool {
|
|
FeatureFlag::MarkdownMermaid.is_enabled()
|
|
&& FeatureFlag::EditableMarkdownMermaid.is_enabled()
|
|
}
|
|
|
|
fn render_mermaid_diagrams_in_state(state: &InteractionState) -> bool {
|
|
FeatureFlag::MarkdownMermaid.is_enabled()
|
|
&& (matches!(state, InteractionState::Selectable)
|
|
|| (Self::editable_markdown_mermaid_enabled()
|
|
&& matches!(
|
|
state,
|
|
InteractionState::Editable | InteractionState::EditableWithInvalidSelection
|
|
)))
|
|
}
|
|
pub fn new(
|
|
text_styles: RichTextStyles,
|
|
rte_window_id: WindowId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
Self::new_internal(text_styles, Some(rte_window_id), ctx)
|
|
}
|
|
|
|
/// Create a model that is not yet bound to a window. The window id should be set later via `set_window_id`.
|
|
pub fn new_unbound(text_styles: RichTextStyles, ctx: &mut ModelContext<Self>) -> Self {
|
|
Self::new_internal(text_styles, None, ctx)
|
|
}
|
|
|
|
fn new_internal(
|
|
text_styles: RichTextStyles,
|
|
rte_window_id: Option<WindowId>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
let content = ctx.add_model(|_| {
|
|
Buffer::new(Box::new(notebook_tab_indentation))
|
|
.with_embedded_item_conversion(super::notebook_embedded_item_conversion)
|
|
});
|
|
ctx.subscribe_to_model(&content, |me, event, ctx| {
|
|
me.handle_content_model_event(event, ctx);
|
|
});
|
|
|
|
let selection_model = ctx.add_model(|_ctx| BufferSelectionModel::new(content.clone()));
|
|
|
|
let render_state = ctx.add_model(|ctx| RenderState::new(text_styles, false, None, ctx));
|
|
ctx.subscribe_to_model(&render_state, Self::handle_render_model_event);
|
|
|
|
let selection = ctx.add_model(|ctx| {
|
|
SelectionModel::new(
|
|
content.clone(),
|
|
render_state.clone(),
|
|
selection_model.clone(),
|
|
None,
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
let interaction_state =
|
|
ctx.add_model(|_| InteractionStateModel::new(InteractionState::Editable));
|
|
ctx.subscribe_to_model(
|
|
&interaction_state,
|
|
Self::handle_interaction_state_model_event,
|
|
);
|
|
|
|
let cloud_model = CloudModel::handle(ctx);
|
|
ctx.subscribe_to_model(&cloud_model, |me, event, ctx| {
|
|
me.handle_cloud_model_event(event, ctx)
|
|
});
|
|
|
|
let (resize_tx, resize_rx) = async_channel::unbounded();
|
|
ctx.spawn_stream_local(
|
|
debounce(DEBOUNCED_RESIZE_PERIOD, resize_rx),
|
|
|me, _, ctx| me.rebuild_layout(ctx),
|
|
|_, _| {},
|
|
);
|
|
|
|
Self {
|
|
render_state,
|
|
content,
|
|
selection,
|
|
selection_model,
|
|
child_models: ChildModels::new(),
|
|
active_text_style: Default::default(),
|
|
active_block_type: BlockType::Text(BufferBlockStyle::PlainText),
|
|
interaction_state,
|
|
rte_window_id,
|
|
resize_tx,
|
|
file_link_resolution_context: None,
|
|
}
|
|
}
|
|
|
|
pub fn interaction_state(&self, ctx: &AppContext) -> InteractionState {
|
|
self.interaction_state.as_ref(ctx).interaction_state()
|
|
}
|
|
|
|
pub fn render_state(&self) -> &ModelHandle<RenderState> {
|
|
&self.render_state
|
|
}
|
|
|
|
pub fn markdown_table_count(&self, ctx: &impl ModelAsRef) -> usize {
|
|
self.render_state.as_ref(ctx).markdown_table_count()
|
|
}
|
|
|
|
pub fn set_interaction_state(
|
|
&mut self,
|
|
new_state: InteractionState,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.interaction_state
|
|
.update(ctx, |interaction_state, ctx| {
|
|
interaction_state.set_interaction_state(new_state, ctx)
|
|
});
|
|
}
|
|
|
|
/// Set the window this editor model is associated with. Should be called when the pane attaches.
|
|
pub fn set_window_id(&mut self, window_id: WindowId, _ctx: &mut ModelContext<Self>) {
|
|
self.rte_window_id = Some(window_id);
|
|
}
|
|
|
|
/// Get the context for the session and working directory associated with this editor, if any.
|
|
/// Used to generate clickable file path links.
|
|
pub fn file_link_resolution_context(&self) -> Option<&FileLinkResolutionContext> {
|
|
self.file_link_resolution_context.as_ref()
|
|
}
|
|
|
|
/// Set the context for the session and working directory associated with this editor, if any.
|
|
/// Used to generate clickable file path links.
|
|
pub fn set_file_link_resolution_context(
|
|
&mut self,
|
|
file_link_resolution_context: Option<FileLinkResolutionContext>,
|
|
) {
|
|
self.file_link_resolution_context = file_link_resolution_context;
|
|
}
|
|
|
|
/// Create a new model for searching the editor.
|
|
pub fn new_search(&self, ctx: &mut ModelContext<Self>) -> ModelHandle<Searcher> {
|
|
let buffer = self.content.clone();
|
|
let selection_model = self.selection_model.clone();
|
|
ctx.add_model(|ctx| Searcher::new(buffer, selection_model, ctx))
|
|
}
|
|
pub fn reset_with_markdown(&mut self, markdown: &str, ctx: &mut ModelContext<Self>) {
|
|
<Self as RichTextEditorModel>::reset_with_markdown(self, markdown, ctx);
|
|
}
|
|
|
|
pub fn update_to_new_markdown(&mut self, markdown: &str, ctx: &mut ModelContext<Self>) {
|
|
<Self as RichTextEditorModel>::update_to_new_markdown(self, markdown, ctx);
|
|
}
|
|
|
|
fn handle_render_model_event(&mut self, event: &RenderEvent, ctx: &mut ModelContext<Self>) {
|
|
// Ignore render events until bound to a real window, and when the window is closed.
|
|
let Some(window_id) = self.rte_window_id else {
|
|
return;
|
|
};
|
|
if !ctx.is_window_open(window_id) {
|
|
log::debug!("Ignoring render event for closed window");
|
|
return;
|
|
}
|
|
|
|
match event {
|
|
RenderEvent::NeedsResize => {
|
|
// When a debounced resize event fires, the model is laid out from scratch, using [`Self::rebuild_layout`].
|
|
let _ = self.resize_tx.try_send(());
|
|
}
|
|
RenderEvent::LayoutUpdated => {
|
|
self.child_models.update(
|
|
self.interaction_state.clone(),
|
|
self.content.clone(),
|
|
self.selection_model.clone(),
|
|
window_id,
|
|
ctx,
|
|
);
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
fn handle_interaction_state_model_event(
|
|
&mut self,
|
|
event: &InteractionStateModelEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let InteractionStateModelEvent::InteractionStateChanged { new_state } = event;
|
|
let show_final_trailing_newline_when_non_empty = matches!(
|
|
new_state,
|
|
InteractionState::Editable | InteractionState::EditableWithInvalidSelection
|
|
);
|
|
self.render_state.update(ctx, |render_state, _| {
|
|
render_state.set_show_final_trailing_newline_when_non_empty(
|
|
show_final_trailing_newline_when_non_empty,
|
|
);
|
|
});
|
|
let render_mermaid_diagrams = Self::render_mermaid_diagrams_in_state(new_state);
|
|
let relayout_needed = self.render_state.update(ctx, |render_state, _| {
|
|
render_state.set_render_mermaid_diagrams(render_mermaid_diagrams)
|
|
});
|
|
if relayout_needed {
|
|
self.rebuild_layout(ctx);
|
|
}
|
|
}
|
|
|
|
fn handle_content_model_event(&mut self, event: &BufferEvent, ctx: &mut ModelContext<Self>) {
|
|
if let Some(window_id) = self.rte_window_id {
|
|
if !ctx.is_window_open(window_id) {
|
|
log::debug!("Ignoring content event for closed window");
|
|
return;
|
|
}
|
|
}
|
|
|
|
let can_edit = matches!(self.interaction_state(ctx), InteractionState::Editable);
|
|
match event {
|
|
BufferEvent::ContentChanged {
|
|
delta,
|
|
origin,
|
|
should_autoscroll,
|
|
buffer_version,
|
|
..
|
|
} => {
|
|
self.render_state.update(ctx, move |render_state, _| {
|
|
render_state.add_pending_edit(delta.clone(), *buffer_version);
|
|
if can_edit {
|
|
match should_autoscroll {
|
|
ShouldAutoscroll::Yes => render_state.request_autoscroll(),
|
|
ShouldAutoscroll::VerticalOnly => {
|
|
render_state.request_vertical_autoscroll()
|
|
}
|
|
ShouldAutoscroll::No => (),
|
|
}
|
|
}
|
|
});
|
|
|
|
ctx.emit(RichTextEditorModelEvent::ContentChanged(*origin));
|
|
}
|
|
BufferEvent::SelectionChanged {
|
|
active_text_styles,
|
|
active_block_type,
|
|
should_autoscroll,
|
|
buffer_version,
|
|
} => {
|
|
let selection_text_styles =
|
|
self.selection_model.as_ref(ctx).selection_text_styles(ctx);
|
|
let mut selections = self
|
|
.content
|
|
.as_ref(ctx)
|
|
.to_rendered_selection_set(self.selection_model.clone(), ctx);
|
|
|
|
for selection in selections.iter_mut() {
|
|
selection.head -= CharOffset::from(1);
|
|
selection.tail -= CharOffset::from(1);
|
|
}
|
|
|
|
self.render_state.update(ctx, move |render_state, _| {
|
|
render_state.update_selection(selections, *buffer_version);
|
|
if matches!(should_autoscroll, AutoScrollBehavior::Selection) && can_edit {
|
|
render_state.request_autoscroll();
|
|
}
|
|
});
|
|
|
|
let text_styles: TextStyles = active_text_styles.clone().into();
|
|
self.active_text_style = text_styles.inheritable();
|
|
self.active_block_type = active_block_type.clone();
|
|
|
|
ctx.emit(RichTextEditorModelEvent::ActiveStylesChanged {
|
|
cursor_text_styles: active_text_styles.clone(),
|
|
selection_text_styles,
|
|
block_type: active_block_type.clone(),
|
|
})
|
|
}
|
|
// Handled by selection model.
|
|
BufferEvent::AnchorUpdated { .. } | BufferEvent::ContentReplaced { .. } => (),
|
|
}
|
|
}
|
|
|
|
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
|
|
// Ignore cloud events until bound to a real window, and when the window is closed.
|
|
let Some(window_id) = self.rte_window_id else {
|
|
return;
|
|
};
|
|
if !ctx.is_window_open(window_id) {
|
|
return;
|
|
}
|
|
match event {
|
|
CloudModelEvent::ObjectUpdated { type_and_id, .. }
|
|
| CloudModelEvent::ObjectTrashed { type_and_id, .. }
|
|
| CloudModelEvent::ObjectUntrashed { type_and_id, .. }
|
|
| CloudModelEvent::ObjectDeleted { type_and_id, .. }
|
|
| CloudModelEvent::ObjectMoved { type_and_id, .. } => {
|
|
if let Some(model) = self
|
|
.child_models
|
|
.model_handles::<NotebookEmbed>()
|
|
.find(|model| model.as_ref(ctx).hashed_id() == type_and_id.sqlite_uid_hash())
|
|
{
|
|
model.update(ctx, |model, ctx| {
|
|
model.refresh_item_state(ctx);
|
|
})
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
/// Find the [`NotebookCommand`] model backing a laid-out block item.
|
|
pub fn notebook_command_for_block(
|
|
&self,
|
|
offset: CharOffset,
|
|
) -> Option<ModelHandle<NotebookCommand>> {
|
|
self.child_models.model_at(offset)
|
|
}
|
|
|
|
pub fn notebook_embed_for_block(
|
|
&self,
|
|
offset: CharOffset,
|
|
) -> Option<ModelHandle<NotebookEmbed>> {
|
|
self.child_models.model_at(offset)
|
|
}
|
|
|
|
pub fn markdown(&self, ctx: &AppContext) -> String {
|
|
self.content.as_ref(ctx).markdown()
|
|
}
|
|
|
|
/// Returns true if the editor's markdown content is empty or contains only whitespace.
|
|
pub fn is_empty(&self, ctx: &AppContext) -> bool {
|
|
self.markdown(ctx).trim().is_empty()
|
|
}
|
|
|
|
/// Returns the markdown content without escaping any special characters.
|
|
pub fn markdown_unescaped(&self, ctx: &AppContext) -> String {
|
|
self.content.as_ref(ctx).markdown_unescaped()
|
|
}
|
|
|
|
/// Returns a debug representation of the buffer contents.
|
|
pub fn debug_buffer(&self, app: &impl ModelAsRef) -> String {
|
|
self.content.as_ref(app).debug()
|
|
}
|
|
|
|
/// Returns a debug representation of the current selection's contents.
|
|
pub fn debug_selection(&self, app: &AppContext) -> String {
|
|
self.content
|
|
.as_ref(app)
|
|
.debug_selection(self.selection_model.clone(), app)
|
|
}
|
|
|
|
pub fn forward_word(&mut self, select: bool, ctx: &mut ModelContext<Self>) {
|
|
self.forward_word_with_unit(select, word_unit(ctx), ctx)
|
|
}
|
|
|
|
pub fn backward_word(&mut self, select: bool, ctx: &mut ModelContext<Self>) {
|
|
self.backward_word_with_unit(select, word_unit(ctx), ctx)
|
|
}
|
|
|
|
pub fn toggle_style(&mut self, style: TextStyles, ctx: &mut ModelContext<Self>) {
|
|
if self.selection_model.as_ref(ctx).all_single_cursors() {
|
|
self.active_text_style ^= style;
|
|
} else {
|
|
let selection_model = self.selection_model.clone();
|
|
self.update_content(
|
|
|mut content, ctx| {
|
|
let selections_to_offset_ranges =
|
|
selection_model.as_ref(ctx).selections_to_offset_ranges();
|
|
let selection_fully_styled = content
|
|
.buffer()
|
|
.ranges_fully_styled(selections_to_offset_ranges, style);
|
|
let action = if selection_fully_styled {
|
|
BufferEditAction::Unstyle(style)
|
|
} else {
|
|
BufferEditAction::Style(style)
|
|
};
|
|
|
|
content.apply_edit(action, EditOrigin::UserInitiated, selection_model, ctx);
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
self.validate(ctx);
|
|
}
|
|
|
|
/// Tests if the given style is active at the cursor.
|
|
pub fn is_style_active(&self, style: BufferTextStyle) -> bool {
|
|
self.active_text_style.exact_match_style(&style)
|
|
}
|
|
|
|
/// Begin selecting at the given `offset`. Returns whether there was previously a command
|
|
/// selection.
|
|
pub fn select_at(
|
|
&mut self,
|
|
offset: CharOffset,
|
|
multiselect: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> bool {
|
|
self.begin_selection(offset, SelectionMode::Character, !multiselect, ctx);
|
|
self.clear_command_selections(ctx)
|
|
}
|
|
|
|
/// Begin semantic selection by word.
|
|
pub fn select_word_at(
|
|
&mut self,
|
|
offset: CharOffset,
|
|
multiselect: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.clear_command_selections(ctx);
|
|
let policy = SemanticSelection::as_ref(ctx).word_boundary_policy();
|
|
self.begin_selection(offset, SelectionMode::Word(policy), !multiselect, ctx);
|
|
}
|
|
|
|
/// Begin semantic selection by line.
|
|
pub fn select_line_at(
|
|
&mut self,
|
|
offset: CharOffset,
|
|
multiselect: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.clear_command_selections(ctx);
|
|
self.begin_selection(offset, SelectionMode::Line, !multiselect, ctx);
|
|
}
|
|
|
|
fn inline_markdown_shortcut(
|
|
&self,
|
|
mut content: BufferUpdateWrapper,
|
|
selection_model: ModelHandle<BufferSelectionModel>,
|
|
ctx: &mut ModelContext<Buffer>,
|
|
) {
|
|
// We will only take an action if all selections can be styled the same way, with the exact same
|
|
// content being selected.
|
|
let mut previous_option = None;
|
|
for cursor in selection_model.as_ref(ctx).selection_heads() {
|
|
let action = self.inline_markdown_for_selection(content.buffer(), cursor);
|
|
if let Some(previous) = &previous_option {
|
|
if *previous != action {
|
|
return;
|
|
}
|
|
} else {
|
|
previous_option = Some(action);
|
|
}
|
|
}
|
|
|
|
if let Some(action) = previous_option {
|
|
match action {
|
|
InlineStyleAction::Insert {
|
|
matched_length,
|
|
text,
|
|
style,
|
|
override_text_style,
|
|
} => {
|
|
NotebooksEditorModel::apply_inline_style_helper(
|
|
content,
|
|
selection_model.clone(),
|
|
matched_length,
|
|
BufferEditAction::Insert {
|
|
text: text.as_str(),
|
|
style,
|
|
override_text_style,
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
InlineStyleAction::Link {
|
|
matched_length,
|
|
tag,
|
|
url,
|
|
} => {
|
|
NotebooksEditorModel::apply_inline_style_helper(
|
|
content,
|
|
selection_model.clone(),
|
|
matched_length,
|
|
BufferEditAction::Link { tag, url },
|
|
ctx,
|
|
);
|
|
}
|
|
InlineStyleAction::None => (),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compute the action that should be taken for a given selection based on the markdown shortcuts input
|
|
/// by the user.
|
|
fn inline_markdown_for_selection(
|
|
&self,
|
|
content: &mut Buffer,
|
|
selection_offset: CharOffset,
|
|
) -> InlineStyleAction {
|
|
let prefix = content.content_from_line_start_to_selection_start(selection_offset);
|
|
|
|
if let Some(cap) = INLINE_CODE_INLINE_REGEX.captures(prefix.as_str()) {
|
|
let matched_length = cap.get(0).unwrap().as_str().chars().count();
|
|
let decorate_string = cap.get(1).unwrap().as_str();
|
|
|
|
InlineStyleAction::Insert {
|
|
matched_length,
|
|
text: decorate_string.to_owned(),
|
|
style: TextStyles::default().inline_code(),
|
|
// We want to override the text style after the insertion to keep the old text style
|
|
// instead of inheriting the newly inserted inline code since it has been closed.
|
|
override_text_style: Some(self.active_text_style),
|
|
}
|
|
} else if let Some(cap) = ITALIC_INLINE_REGEX.captures(prefix.as_str()) {
|
|
let matched_length = cap.get(0).unwrap().as_str().chars().count();
|
|
let decorate_string = cap.get(1).unwrap().as_str();
|
|
|
|
// We need to look ahead to make sure the character in front of the matched regex is not "*".
|
|
// This is to prevent **abc* to be interpreted as italic when user intends to type in bold inline markdown.
|
|
//
|
|
// Unfortunately Rust Regex does not support look ahead for performance reasons
|
|
// https://github.com/rust-lang/regex/issues/127
|
|
// As a workaround, we need to manually assert if the previous character is * or not.
|
|
let should_apply_inline_style = if selection_offset.as_usize() > matched_length + 1 {
|
|
content.char_at(selection_offset - matched_length - 1) != Some('*')
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if should_apply_inline_style {
|
|
InlineStyleAction::Insert {
|
|
matched_length,
|
|
text: decorate_string.to_owned(),
|
|
style: TextStyles::default().italic(),
|
|
override_text_style: Some(self.active_text_style),
|
|
}
|
|
} else {
|
|
InlineStyleAction::None
|
|
}
|
|
} else if let Some(cap) = BOLD_INLINE_REGEX.captures(prefix.as_str()) {
|
|
let matched_length = cap.get(0).unwrap().as_str().chars().count();
|
|
let decorate_string = cap.get(1).unwrap().as_str();
|
|
|
|
// Unfortunately Rust Regex does not support look ahead for performance reasons
|
|
// https://github.com/rust-lang/regex/issues/127
|
|
// As a workaround, we need to manually assert if the previous character is * or not.
|
|
let should_apply_inline_style = if selection_offset.as_usize() > matched_length + 1 {
|
|
content.char_at(selection_offset - matched_length - 1) != Some('*')
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if should_apply_inline_style {
|
|
InlineStyleAction::Insert {
|
|
matched_length,
|
|
text: decorate_string.to_owned(),
|
|
style: TextStyles::default().bold(),
|
|
override_text_style: Some(self.active_text_style),
|
|
}
|
|
} else {
|
|
InlineStyleAction::None
|
|
}
|
|
} else if let Some(cap) = BOLD_ITALIC_INLINE_REGEX.captures(prefix.as_str()) {
|
|
let matched_length = cap.get(0).unwrap().as_str().chars().count();
|
|
let decorate_string = cap.get(1).unwrap().as_str();
|
|
|
|
InlineStyleAction::Insert {
|
|
matched_length,
|
|
text: decorate_string.to_owned(),
|
|
style: TextStyles::default().bold().italic(),
|
|
override_text_style: Some(self.active_text_style),
|
|
}
|
|
} else if let Some(action) =
|
|
self.underscore_inline_markdown_action(prefix.as_str(), content, selection_offset)
|
|
{
|
|
action
|
|
} else if let Some(cap) = LINK_INLINE_REGEX.captures(prefix.as_str()) {
|
|
let matched_length = cap.get(0).unwrap().as_str().chars().count();
|
|
let decorate_string = cap.get(1).unwrap().as_str();
|
|
let link = cap.get(2).unwrap().as_str();
|
|
|
|
InlineStyleAction::Link {
|
|
matched_length,
|
|
tag: decorate_string.to_string(),
|
|
url: link.to_string(),
|
|
}
|
|
} else if let Some(cap) = STRIKETHROUGH_REGEX.captures(prefix.as_str()) {
|
|
let matched_length = cap.get(0).unwrap().as_str().chars().count();
|
|
let decorate_string = cap.get(1).unwrap().as_str();
|
|
|
|
InlineStyleAction::Insert {
|
|
matched_length,
|
|
text: decorate_string.to_string(),
|
|
style: TextStyles::default().strikethrough(),
|
|
override_text_style: Some(self.active_text_style),
|
|
}
|
|
} else {
|
|
InlineStyleAction::None
|
|
}
|
|
}
|
|
|
|
/// Compute the inline-markdown action for the underscore variants of
|
|
/// italic (`_x_`), bold (`__x__`), and bold+italic (`___x___`). The
|
|
/// variants share identical structure, so we dispatch them through a
|
|
/// single table keyed by regex. Rules for `_`:
|
|
/// - reject a match preceded by another `_` for italic/bold so that
|
|
/// mid-sequence matches (e.g. `__abc_` on the way to `__abc__`) don't
|
|
/// trigger prematurely,
|
|
/// - always reject a match preceded by an alphanumeric character,
|
|
/// mirroring CommonMark's left-flanking rules for `_` so intra-word
|
|
/// underscores such as `foo_bar_` are not coerced.
|
|
fn underscore_inline_markdown_action(
|
|
&self,
|
|
prefix: &str,
|
|
content: &Buffer,
|
|
selection_offset: CharOffset,
|
|
) -> Option<InlineStyleAction> {
|
|
// Ordered shortest → longest. The stricter variants (italic, bold)
|
|
// also reject a leading `_` to avoid firing while the user is still
|
|
// typing a longer delimiter run.
|
|
let candidates: [(&Regex, TextStyles, bool); 3] = [
|
|
(
|
|
&ITALIC_UNDERSCORE_INLINE_REGEX,
|
|
TextStyles::default().italic(),
|
|
true,
|
|
),
|
|
(
|
|
&BOLD_UNDERSCORE_INLINE_REGEX,
|
|
TextStyles::default().bold(),
|
|
true,
|
|
),
|
|
(
|
|
&BOLD_ITALIC_UNDERSCORE_INLINE_REGEX,
|
|
TextStyles::default().bold().italic(),
|
|
false,
|
|
),
|
|
];
|
|
|
|
for (regex, style, reject_underscore_prefix) in candidates {
|
|
let Some(cap) = regex.captures(prefix) else {
|
|
continue;
|
|
};
|
|
let matched_length = cap.get(0).unwrap().as_str().chars().count();
|
|
let decorate_string = cap.get(1).unwrap().as_str();
|
|
|
|
// Only inspect the character immediately before the match when the
|
|
// match starts far enough from the buffer's beginning — i.e. when
|
|
// there actually is a character at that position to look at.
|
|
let should_apply_inline_style = if selection_offset.as_usize() > matched_length + 1 {
|
|
!matches!(
|
|
content.char_at(selection_offset - matched_length - 1),
|
|
Some(c) if (reject_underscore_prefix && c == '_') || c.is_alphanumeric()
|
|
)
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if should_apply_inline_style {
|
|
return Some(InlineStyleAction::Insert {
|
|
matched_length,
|
|
text: decorate_string.to_owned(),
|
|
style,
|
|
override_text_style: Some(self.active_text_style),
|
|
});
|
|
} else {
|
|
return Some(InlineStyleAction::None);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn block_markdown_shortcut<'a>(
|
|
content: &mut Buffer,
|
|
selection_model: ModelHandle<BufferSelectionModel>,
|
|
ctx: &AppContext,
|
|
text: &'a str,
|
|
) -> Option<BufferEditAction<'a>> {
|
|
// We will only return an action if all selections can be converted to the desired block type.
|
|
let mut consensus_block_type = None;
|
|
for range in selection_model.as_ref(ctx).selections_to_offset_ranges() {
|
|
let found_block_type = match (
|
|
text,
|
|
content
|
|
.content_from_block_start_to_selection_start(range.start)
|
|
.as_str(),
|
|
) {
|
|
("`", "```") => Some(BlockType::Text(BufferBlockStyle::CodeBlock {
|
|
code_block_type: Default::default(),
|
|
})),
|
|
(" ", "# ") => Some(BlockType::Text(BufferBlockStyle::Header {
|
|
header_size: BlockHeaderSize::Header1,
|
|
})),
|
|
(" ", "## ") => Some(BlockType::Text(BufferBlockStyle::Header {
|
|
header_size: BlockHeaderSize::Header2,
|
|
})),
|
|
(" ", "### ") => Some(BlockType::Text(BufferBlockStyle::Header {
|
|
header_size: BlockHeaderSize::Header3,
|
|
})),
|
|
(" ", "#### ") => Some(BlockType::Text(BufferBlockStyle::Header {
|
|
header_size: BlockHeaderSize::Header4,
|
|
})),
|
|
(" ", "##### ") => Some(BlockType::Text(BufferBlockStyle::Header {
|
|
header_size: BlockHeaderSize::Header5,
|
|
})),
|
|
(" ", "###### ") => Some(BlockType::Text(BufferBlockStyle::Header {
|
|
header_size: BlockHeaderSize::Header6,
|
|
})),
|
|
(" ", "* " | "- ") => Some(BlockType::Text(BufferBlockStyle::UnorderedList {
|
|
indent_level: Self::list_indent_at_selection(
|
|
content,
|
|
selection_model.clone(),
|
|
ctx,
|
|
)
|
|
.unwrap_or(ListIndentLevel::One),
|
|
})),
|
|
(" ", text) => {
|
|
if HORIZONTAL_RULE_SHORTCUT_PREFIX.is_match(text)
|
|
&& content.block_type_at_point(range.start)
|
|
== BlockType::Text(BufferBlockStyle::PlainText)
|
|
{
|
|
Some(BlockType::Item(BufferBlockItem::HorizontalRule))
|
|
} else if COMPLETE_TASKLIST_INLINE_REGEX.is_match(text) {
|
|
Some(BlockType::Text(BufferBlockStyle::TaskList {
|
|
indent_level: Self::list_indent_at_selection(
|
|
content,
|
|
selection_model.clone(),
|
|
ctx,
|
|
)
|
|
.unwrap_or(ListIndentLevel::One),
|
|
complete: true,
|
|
}))
|
|
} else if INCOMPLETE_TASKLIST_INLINE_REGEX.is_match(text) {
|
|
Some(BlockType::Text(BufferBlockStyle::TaskList {
|
|
indent_level: Self::list_indent_at_selection(
|
|
content,
|
|
selection_model.clone(),
|
|
ctx,
|
|
)
|
|
.unwrap_or(ListIndentLevel::One),
|
|
complete: false,
|
|
}))
|
|
} else if let Some(captures) = NUMBERED_LIST_SHORTCUT_PREFIX.captures(text) {
|
|
// Calculate the new ordered list item's indent level:
|
|
// * If the cursor is at another type of list, reuse the indent level (for
|
|
// example, to convert fron an unordered list to an ordered list)
|
|
// * If the cursor is already in the middle of an ordered list, skip the
|
|
// shortcut because it would have no effect (see the
|
|
// `BufferBlockStyle::OrderedList` case for details).
|
|
// * Otherwise, start at level one.
|
|
let active_block_type = content
|
|
.active_block_type_at_first_selection(selection_model.as_ref(ctx));
|
|
let indent_level = match active_block_type {
|
|
BlockType::Text(BufferBlockStyle::UnorderedList { indent_level }) => {
|
|
Some(indent_level)
|
|
}
|
|
BlockType::Text(BufferBlockStyle::TaskList {
|
|
indent_level, ..
|
|
}) => Some(indent_level),
|
|
BlockType::Text(BufferBlockStyle::OrderedList {
|
|
indent_level, ..
|
|
}) => {
|
|
let block_start = content.block_or_line_start(
|
|
selection_model.as_ref(ctx).first_selection_head(),
|
|
);
|
|
|
|
// If the previous block is an ordered list item that's equally- or
|
|
// more-indented than the ordered list item at the cursor, then we're
|
|
// in the middle of an ordered list and cannot renumber it. For
|
|
// example, given this list:
|
|
// 1. AAAA
|
|
// 2. BBBB
|
|
// a) CCCC
|
|
// b) DDDD
|
|
// 3. EEEE
|
|
// The shortcut is valid at AAAA and CCCC, but not BBBB, DDDD, or EEEE.
|
|
if block_start > CharOffset::from(1) {
|
|
match content.block_type_at_point(block_start - 1) {
|
|
BlockType::Text(BufferBlockStyle::OrderedList {
|
|
indent_level: previous_indent_level,
|
|
..
|
|
}) if previous_indent_level >= indent_level => None,
|
|
_ => Some(indent_level),
|
|
}
|
|
} else {
|
|
// If this is the first block in the buffer, it cannot possibly
|
|
// be in the middle of a list.
|
|
Some(indent_level)
|
|
}
|
|
}
|
|
_ => Some(ListIndentLevel::One),
|
|
};
|
|
|
|
indent_level.map(|indent_level| {
|
|
let number = captures
|
|
.get(1)
|
|
.and_then(|number| number.as_str().parse::<usize>().ok());
|
|
BlockType::Text(BufferBlockStyle::OrderedList {
|
|
indent_level,
|
|
number,
|
|
})
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
_ => return None,
|
|
};
|
|
|
|
// If we didn't find a block type to convert to, or if we found a different block type, than
|
|
// found at a previous selection, we won't convert anything.
|
|
match (found_block_type, &consensus_block_type) {
|
|
(None, _) => return None,
|
|
(found, None) => consensus_block_type = found,
|
|
(Some(found), Some(consensus)) if found != *consensus => return None,
|
|
_ => (),
|
|
}
|
|
}
|
|
consensus_block_type.map(BufferEditAction::RemovePrefixAndStyleBlocks)
|
|
}
|
|
|
|
fn apply_inline_style_helper(
|
|
mut content: BufferUpdateWrapper,
|
|
selection_model: ModelHandle<BufferSelectionModel>,
|
|
matched_length: usize,
|
|
action: BufferEditAction,
|
|
ctx: &mut ModelContext<Buffer>,
|
|
) {
|
|
let new_selections = selection_model
|
|
.as_ref(ctx)
|
|
.selections_to_offset_ranges()
|
|
.mapped(|range| SelectionOffsets {
|
|
tail: range
|
|
.start
|
|
.saturating_sub(&CharOffset::from(matched_length)),
|
|
head: range.end,
|
|
});
|
|
|
|
content.buffer().update_selection(
|
|
selection_model.clone(),
|
|
BufferSelectAction::SetSelectionOffsets {
|
|
selections: new_selections,
|
|
},
|
|
AutoScrollBehavior::Selection,
|
|
ctx,
|
|
);
|
|
|
|
content.apply_edit(action, EditOrigin::UserTyped, selection_model, ctx);
|
|
}
|
|
|
|
/// Copy the current selection. If a code block is selected, copy its entire contents.
|
|
pub fn copy(&self, ctx: &mut ModelContext<Self>) -> Option<BlockInfo> {
|
|
let (clipboard, block) = match self.single_selected_command_range(ctx) {
|
|
SelectedCommandResult::Single { start, end } => {
|
|
let clipboard = self.command_clipboard_content(start, end, ctx);
|
|
(clipboard, Some(BlockInfo::CodeBlock))
|
|
}
|
|
SelectedCommandResult::None if !self.selection_is_single_cursor(ctx) => {
|
|
(self.read_selected_text_as_clipboard_content(ctx), None)
|
|
}
|
|
_ => return None,
|
|
};
|
|
|
|
ctx.clipboard().write(clipboard);
|
|
block
|
|
}
|
|
|
|
/// Cut the current text or command selection.
|
|
pub fn cut(&mut self, ctx: &mut ModelContext<Self>) -> Option<BlockInfo> {
|
|
match self.single_selected_command_range(ctx) {
|
|
SelectedCommandResult::Single { start, end } => {
|
|
self.delete_selected_command_range(start, end, true, ctx);
|
|
Some(BlockInfo::CodeBlock)
|
|
}
|
|
SelectedCommandResult::None if !self.selection_is_single_cursor(ctx) => {
|
|
let clipboard = self.read_selected_text_as_clipboard_content(ctx);
|
|
ctx.clipboard().write(clipboard);
|
|
|
|
let selection_model = self.selection_model.clone();
|
|
self.update_content(
|
|
|mut content, ctx| {
|
|
content.apply_edit(
|
|
BufferEditAction::Backspace,
|
|
EditOrigin::UserInitiated,
|
|
selection_model,
|
|
ctx,
|
|
)
|
|
},
|
|
ctx,
|
|
);
|
|
self.validate(ctx);
|
|
None
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn insert_formatted_from_paste(
|
|
&mut self,
|
|
formatted_text: FormattedText,
|
|
plain_text: &str,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
match self.single_selected_command_range(ctx) {
|
|
SelectedCommandResult::Single { start, end } => {
|
|
self.selection.update(ctx, |selection, ctx| {
|
|
// Add 1 to skip past the command's starting block marker.
|
|
selection.update_selection(
|
|
BufferSelectAction::SetSelectionOffsets {
|
|
selections: vec1![SelectionOffsets {
|
|
head: end,
|
|
tail: start + 1
|
|
}],
|
|
},
|
|
AutoScrollBehavior::Selection,
|
|
ctx,
|
|
)
|
|
});
|
|
}
|
|
SelectedCommandResult::Multiple => return,
|
|
SelectedCommandResult::None => (),
|
|
}
|
|
|
|
// If the pasted content is a URL, and there is only one selection that is a range (not just a cursor),
|
|
// then we will create a link for the selected text, instead of pasting the text into the buffer.
|
|
let single_range_selection = self.selection_is_single_range(ctx);
|
|
let selection_model = self.selection_model.clone();
|
|
|
|
let all_selections_allow_formatting = selection_model
|
|
.as_ref(ctx)
|
|
.all_selections_allow_formatting(ctx);
|
|
|
|
self.update_content(
|
|
|mut content, ctx| {
|
|
if all_selections_allow_formatting {
|
|
// If we have an active selection and the pasted in text is a url, set the active selection to
|
|
// a link instead of replacing the content.
|
|
match is_valid_url(plain_text) {
|
|
Some(url) if single_range_selection => {
|
|
let tag = content
|
|
.buffer()
|
|
.selected_text_as_plain_text(selection_model.clone(), ctx)
|
|
.into_string();
|
|
content.apply_edit(
|
|
BufferEditAction::Link { tag, url },
|
|
EditOrigin::UserInitiated,
|
|
selection_model.clone(),
|
|
ctx,
|
|
)
|
|
}
|
|
_ => content.apply_edit(
|
|
BufferEditAction::InsertFormatted(formatted_text),
|
|
EditOrigin::UserInitiated,
|
|
selection_model.clone(),
|
|
ctx,
|
|
),
|
|
}
|
|
} else {
|
|
content.apply_edit(
|
|
BufferEditAction::Insert {
|
|
text: plain_text,
|
|
style: TextStyles::default(),
|
|
override_text_style: None,
|
|
},
|
|
EditOrigin::UserInitiated,
|
|
selection_model,
|
|
ctx,
|
|
)
|
|
}
|
|
},
|
|
ctx,
|
|
);
|
|
self.validate(ctx);
|
|
}
|
|
|
|
fn delete_selected_command_range(
|
|
&mut self,
|
|
start: CharOffset,
|
|
end: CharOffset,
|
|
cut: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if cut {
|
|
let clipboard = self.command_clipboard_content(start, end, ctx);
|
|
ctx.clipboard().write(clipboard);
|
|
}
|
|
|
|
let selection_model = self.buffer_selection_model().clone();
|
|
self.update_content(
|
|
|mut content, ctx| {
|
|
content.apply_edit(
|
|
BufferEditAction::Delete(vec1![start..end]),
|
|
EditOrigin::UserInitiated,
|
|
selection_model,
|
|
ctx,
|
|
);
|
|
},
|
|
ctx,
|
|
);
|
|
self.validate(ctx);
|
|
}
|
|
|
|
pub fn selected_text(&self, ctx: &AppContext) -> String {
|
|
self.content
|
|
.as_ref(ctx)
|
|
.selected_text_as_plain_text(self.selection_model.clone(), ctx)
|
|
.into_string()
|
|
}
|
|
|
|
pub fn has_single_exact_rendered_mermaid_selection(&self, ctx: &AppContext) -> bool {
|
|
if !self.selection_is_single_range(ctx) {
|
|
return false;
|
|
}
|
|
|
|
self.selection_model
|
|
.as_ref(ctx)
|
|
.selections_to_offset_ranges()
|
|
.into_iter()
|
|
.exactly_one()
|
|
.ok()
|
|
.is_some_and(|range| {
|
|
self.render_state
|
|
.as_ref(ctx)
|
|
.is_entire_range_of_type(&range, |item| {
|
|
matches!(item, BlockItem::MermaidDiagram { .. })
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Collapse all selections to cursors (set head == tail), effectively clearing
|
|
/// the visual selection highlight without changing cursor positions.
|
|
pub fn collapse_selections_to_cursors(&self, ctx: &mut ModelContext<Self>) {
|
|
self.selection.update(ctx, |selection, ctx| {
|
|
let first_selection = *selection.selections(ctx).first();
|
|
selection.update_selection(
|
|
BufferSelectAction::SetSelectionOffsets {
|
|
selections: vec1![SelectionOffsets {
|
|
head: first_selection.head,
|
|
tail: first_selection.head,
|
|
}],
|
|
},
|
|
AutoScrollBehavior::None,
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
|
|
fn is_rendered_mermaid_block(&self, block_start: CharOffset, ctx: &AppContext) -> bool {
|
|
matches!(
|
|
self.content
|
|
.as_ref(ctx)
|
|
.block_type_at_point(block_start + 1),
|
|
BlockType::Text(BufferBlockStyle::CodeBlock {
|
|
code_block_type: CodeBlockType::Mermaid,
|
|
})
|
|
) && Self::editable_markdown_mermaid_enabled()
|
|
&& matches!(
|
|
self.interaction_state(ctx),
|
|
InteractionState::Selectable
|
|
| InteractionState::Editable
|
|
| InteractionState::EditableWithInvalidSelection
|
|
)
|
|
}
|
|
|
|
fn rendered_mermaid_ranges(&self, ctx: &AppContext) -> Vec<Range<CharOffset>> {
|
|
self.child_models
|
|
.models
|
|
.iter()
|
|
.filter_map(|(start, command)| {
|
|
let end = command.end_offset(ctx)?;
|
|
self.is_rendered_mermaid_block(*start, ctx)
|
|
.then_some(*start..end)
|
|
})
|
|
.sorted_by_key(|range| range.start)
|
|
.collect_vec()
|
|
}
|
|
|
|
pub fn link_at_selection_head(&self, ctx: &AppContext) -> Option<String> {
|
|
self.content
|
|
.as_ref(ctx)
|
|
.link_url_at_selection_head(self.selection_model.clone(), ctx)
|
|
}
|
|
|
|
/// Update the rich text styling used to render the buffer. This will generally re-layout the
|
|
/// entire buffer, unless the styles haven't changed.
|
|
pub fn update_rich_text_styles(
|
|
&self,
|
|
new_styles: RichTextStyles,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
// Inline code colors and the underline color (derived from base_text.text_color) are
|
|
// baked into the layout cache. Capture whether they changed before the styles are
|
|
// replaced, so we can force a relayout for notebooks when only those colors differ.
|
|
let notebook_colors_changed = {
|
|
let current = self.render_state.as_ref(ctx).styles();
|
|
current.inline_code_style != new_styles.inline_code_style
|
|
|| current.base_text.text_color != new_styles.base_text.text_color
|
|
};
|
|
|
|
let style_update = self.render_state.update(ctx, |render_state, _| {
|
|
render_state.update_styles(new_styles)
|
|
});
|
|
// TODO: We can skip work here based on what's changed. For example, if the code text style
|
|
// changes, we only need to re-layout code blocks.
|
|
match style_update {
|
|
StyleUpdateAction::Relayout => self.rebuild_layout(ctx),
|
|
StyleUpdateAction::Repaint if notebook_colors_changed => self.rebuild_layout(ctx),
|
|
StyleUpdateAction::Repaint => (),
|
|
StyleUpdateAction::None => return,
|
|
};
|
|
|
|
// Re-apply cached highlighting for models when there is a theme update.
|
|
for model in self.child_models.model_handles::<NotebookEmbed>() {
|
|
model.update(ctx, |model, ctx| model.try_apply_cached_highlighting(ctx));
|
|
}
|
|
|
|
for model in self.child_models.model_handles::<NotebookCommand>() {
|
|
model.update(ctx, |model, ctx| model.try_apply_cached_highlighting(ctx));
|
|
}
|
|
}
|
|
|
|
pub fn link_url_at(&self, offset: CharOffset, app: &AppContext) -> Option<String> {
|
|
self.content.as_ref(app).link_url_at_offset(offset)
|
|
}
|
|
|
|
/// Whether or not there's an active command block selection.
|
|
pub fn has_command_selection(&self, ctx: &AppContext) -> bool {
|
|
self.child_models
|
|
.models
|
|
.values()
|
|
.any(|model| model.selected(ctx))
|
|
}
|
|
|
|
/// Selects the command block at the given start offset. If the offset does not point to a
|
|
/// command block, this has no effect.
|
|
pub fn select_command_at(&mut self, block_start: CharOffset, ctx: &mut ModelContext<Self>) {
|
|
let child_model = match self.child_models.models.get(&block_start) {
|
|
Some(handle) => handle.clone_boxed(),
|
|
None => return,
|
|
};
|
|
|
|
// If the selection is on a valid block, we clear selections first so that if there
|
|
// were any other selected commands, the end result is that _just_ the given command
|
|
// is selected.
|
|
let had_command_selection = self.clear_command_selections(ctx);
|
|
|
|
self.cursor_at(block_start, ctx);
|
|
|
|
if !child_model.selectable(ctx) {
|
|
return;
|
|
}
|
|
|
|
child_model.set_selected(true, ctx);
|
|
|
|
if let Some(command) = child_model.executable_command(ctx) {
|
|
ctx.emit_a11y_content(AccessibilityContent::new_without_help(
|
|
format!("Selected workflow: {command}"),
|
|
WarpA11yRole::TextareaRole,
|
|
));
|
|
}
|
|
|
|
match child_model.end_offset(ctx) {
|
|
Some(end_offset) => {
|
|
let range = block_start..end_offset;
|
|
self.render_state.update(ctx, |render_state, _| {
|
|
render_state
|
|
.request_autoscroll_to(AutoScrollMode::ScrollOffsetsIntoViewport(range));
|
|
});
|
|
}
|
|
None => {
|
|
log::error!("Child model at {block_start} has end offset with value None");
|
|
}
|
|
};
|
|
|
|
self.interaction_state
|
|
.update(ctx, |interaction_state, ctx| {
|
|
interaction_state.set_is_block_selected(true, ctx);
|
|
});
|
|
|
|
if !had_command_selection {
|
|
ctx.emit(RichTextEditorModelEvent::SwitchedSelectionMode {
|
|
new_mode: TelemetrySelectionMode::Command,
|
|
});
|
|
};
|
|
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Selects the command block containing the text cursor. If the cursor is not inside a command
|
|
/// block, this has no effect.
|
|
///
|
|
/// Unlike `select_command_up`, the text cursor must be _within_ the command block. This will
|
|
/// not seek up to the previous command block if the cursor is within another type of block.
|
|
pub fn select_command_at_cursor(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let cursor = self.selection_model.as_ref(ctx).first_selection_head();
|
|
let content = self.content.as_ref(ctx);
|
|
// Subtract 1 to get to the start marker for the block.
|
|
self.select_command_at(content.block_or_line_start(cursor) - 1, ctx);
|
|
}
|
|
|
|
/// Selects the next command block up from the current selection. This is either:
|
|
/// * The command block before the first selected command in the buffer.
|
|
/// * The nearest command block above the text cursor.
|
|
pub fn select_command_up(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let selected_command_start = self.selected_commands(ctx).map(|(start, _)| start).min();
|
|
|
|
let selectable_offsets = self.child_models.selectable_offsets(ctx);
|
|
|
|
if selectable_offsets.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Calculates the char offset of the previous selectable block. We first get the index of the current
|
|
// selection offset (if we have a block selection, use the starting offset of the block instead) in the
|
|
// selectable offsets. If the index is 0, this is a no-op. Otherwise, select the block with index - 1.
|
|
let has_command_selection = selected_command_start.is_some();
|
|
let current_offset = selected_command_start
|
|
.unwrap_or_else(|| self.selection_model.as_ref(ctx).first_selection_head());
|
|
|
|
let previous_command_offset = match selectable_offsets.binary_search(¤t_offset) {
|
|
Ok(0) if !has_command_selection => None,
|
|
Ok(0) => selectable_offsets.first(),
|
|
Err(0) => None,
|
|
Ok(num) | Err(num) => selectable_offsets.get(num - 1),
|
|
};
|
|
|
|
if let Some(offset) = previous_command_offset {
|
|
self.select_command_at(*offset, ctx)
|
|
}
|
|
}
|
|
|
|
/// Selects the next command block down from the current selection. This is either:
|
|
/// * The command block after the last selected command in the buffer.
|
|
/// * The nearest command block below the text cursor.
|
|
pub fn select_command_down(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let selected_command_start = self.selected_commands(ctx).map(|(start, _)| start).max();
|
|
|
|
let selectable_offsets = self.child_models.selectable_offsets(ctx);
|
|
|
|
if selectable_offsets.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Calculates the char offset of the next selectable block. We first get the index of the current
|
|
// selection offset (if we have a block selection, use the starting offset of the block instead) in the
|
|
// selectable offsets. If the index is at last index, this is a no-op. Otherwise, select the block with index + 1.
|
|
let has_command_selection = selected_command_start.is_some();
|
|
let current_offset = selected_command_start
|
|
.unwrap_or_else(|| self.selection_model.as_ref(ctx).first_selection_head());
|
|
|
|
// Should be safe since we checked selectable_offset is not empty above.
|
|
let last_idx = selectable_offsets.len() - 1;
|
|
let next_command_offset = match selectable_offsets.binary_search(¤t_offset) {
|
|
// If we are already at the end of the selectable block or if we don't have a command selection currently,
|
|
// select the block matching the index.
|
|
Ok(num) if num == last_idx || !has_command_selection => selectable_offsets.get(num),
|
|
Ok(num) => selectable_offsets.get(num + 1),
|
|
Err(num) => selectable_offsets.get(num),
|
|
};
|
|
|
|
if let Some(offset) = next_command_offset {
|
|
self.select_command_at(*offset, ctx)
|
|
}
|
|
}
|
|
|
|
/// Switch from command block selection to text selection. This de-selects all commands in the
|
|
/// buffer and moves the text cursor to the end of the first selected block.
|
|
pub fn exit_command_selection(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let new_cursor_location = self
|
|
.selected_commands(ctx)
|
|
.min_by_key(|(start, _)| *start)
|
|
.and_then(|(_, command)| command.end_offset(ctx));
|
|
|
|
if self.clear_command_selections(ctx) {
|
|
ctx.emit(RichTextEditorModelEvent::SwitchedSelectionMode {
|
|
new_mode: TelemetrySelectionMode::Text,
|
|
});
|
|
}
|
|
|
|
if let Some(cursor_location) = new_cursor_location {
|
|
self.cursor_at(cursor_location, ctx);
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Marks all command blocks as not selected and resets the last active select block state.
|
|
///
|
|
/// Returns whether or not any commands were previously selected.
|
|
pub fn clear_command_selections(&mut self, ctx: &mut ModelContext<Self>) -> bool {
|
|
let mut had_command_selection = false;
|
|
for command in self.child_models.models.values() {
|
|
if command.selectable(ctx) {
|
|
had_command_selection |= command.set_selected(false, ctx)
|
|
}
|
|
}
|
|
self.interaction_state
|
|
.update(ctx, |interaction_state, ctx| {
|
|
interaction_state.set_is_block_selected(false, ctx);
|
|
});
|
|
|
|
had_command_selection
|
|
}
|
|
|
|
/// Returns true if any of the subviews store on any of the NotebookCommand models are focused
|
|
/// (such as the dropdown view for picking different block types)
|
|
pub fn any_notebook_command_submodel_focused(&self, ctx: &AppContext) -> bool {
|
|
self.child_models
|
|
.models::<NotebookCommand>(ctx)
|
|
.any(|command| command.is_dropdown_focused(ctx))
|
|
}
|
|
|
|
/// The currently-selected command blocks and their starting offsets.
|
|
fn selected_commands<'a>(
|
|
&'a self,
|
|
ctx: &'a AppContext,
|
|
) -> impl Iterator<Item = (CharOffset, &'a Box<dyn ChildModelHandle>)> + 'a {
|
|
self.child_models
|
|
.models
|
|
.values()
|
|
.filter_map(|handle| match handle.start_offset(ctx) {
|
|
Some(offset) if handle.selected(ctx) => Some((offset, handle)),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// The currently-selected command block, as a [`NotebookWorkflow`]
|
|
pub fn selected_command_workflow(&self, app: &AppContext) -> Option<NotebookWorkflow> {
|
|
// TODO(ben): Support executing multiple commands. It's TBD how we'll queue them up.
|
|
self.selected_commands(app)
|
|
.exactly_one()
|
|
.ok()
|
|
.and_then(|(_, command)| command.executable_workflow(app))
|
|
}
|
|
|
|
/// The range of the currently-selected command.
|
|
fn single_selected_command_range(&self, ctx: &AppContext) -> SelectedCommandResult {
|
|
match self.selected_commands(ctx).at_most_one() {
|
|
Ok(Some((start, command))) => match command.end_offset(ctx) {
|
|
Some(end) => SelectedCommandResult::Single { start, end },
|
|
None => SelectedCommandResult::None,
|
|
},
|
|
Ok(None) => SelectedCommandResult::None,
|
|
Err(_) => SelectedCommandResult::Multiple,
|
|
}
|
|
}
|
|
|
|
fn maybe_rendered_mermaid_deletion_ranges(
|
|
&self,
|
|
direction: TextDirection,
|
|
unit: TextUnit,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Option<Vec1<Range<CharOffset>>> {
|
|
let mermaid_ranges = self.rendered_mermaid_ranges(ctx);
|
|
if mermaid_ranges.is_empty() {
|
|
return None;
|
|
}
|
|
let all_single_cursors = self.selection_model.as_ref(ctx).all_single_cursors();
|
|
if !all_single_cursors {
|
|
let ranges = self
|
|
.selection_model
|
|
.as_ref(ctx)
|
|
.selections_to_offset_ranges();
|
|
return (ranges.len() == 1
|
|
&& mermaid_ranges.iter().any(|range| range == ranges.first()))
|
|
.then_some(ranges);
|
|
}
|
|
|
|
let ranges = self.replacement_range_for_deletion(direction, unit.clone(), ctx)?;
|
|
|
|
let allow_adjacent_boundary = matches!(unit, TextUnit::Character);
|
|
let mut changed = false;
|
|
let ranges = ranges.mapped_ref(|range| {
|
|
let expanded = mermaid_ranges
|
|
.iter()
|
|
.fold(range.clone(), |current, mermaid_range| {
|
|
let intersects =
|
|
current.start < mermaid_range.end && mermaid_range.start < current.end;
|
|
let is_adjacent_boundary = allow_adjacent_boundary
|
|
&& ((matches!(direction, TextDirection::Backwards)
|
|
&& current.start == mermaid_range.end)
|
|
|| (matches!(direction, TextDirection::Forwards)
|
|
&& current.end == mermaid_range.start));
|
|
if intersects {
|
|
current.start.min(mermaid_range.start)..current.end.max(mermaid_range.end)
|
|
} else if is_adjacent_boundary {
|
|
mermaid_range.clone()
|
|
} else {
|
|
current
|
|
}
|
|
});
|
|
if expanded != *range {
|
|
changed = true;
|
|
}
|
|
expanded
|
|
});
|
|
|
|
changed.then_some(ranges)
|
|
}
|
|
|
|
fn clipboard_content_for_ranges(
|
|
&self,
|
|
ranges: Vec1<Range<CharOffset>>,
|
|
ctx: &AppContext,
|
|
) -> ClipboardContent {
|
|
if ranges.len() == 1 {
|
|
let range = ranges.first();
|
|
if self
|
|
.render_state
|
|
.as_ref(ctx)
|
|
.is_entire_range_of_type(range, |item| {
|
|
matches!(item, BlockItem::MermaidDiagram { .. })
|
|
})
|
|
{
|
|
return self.command_clipboard_content(range.start, range.end, ctx);
|
|
}
|
|
}
|
|
|
|
let buffer = self.content.as_ref(ctx);
|
|
ClipboardContent {
|
|
plain_text: buffer.text_in_ranges_with_expanded_embedded_items(ranges.clone(), ctx),
|
|
html: buffer.ranges_as_html(ranges, ctx),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn delete_ranges(
|
|
&mut self,
|
|
ranges: Vec1<Range<CharOffset>>,
|
|
cut: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if cut {
|
|
let clipboard = self.clipboard_content_for_ranges(ranges.clone(), ctx);
|
|
ctx.clipboard().write(clipboard);
|
|
}
|
|
|
|
let selection_model = self.buffer_selection_model().clone();
|
|
self.update_content(
|
|
|mut content, ctx| {
|
|
content.apply_edit(
|
|
BufferEditAction::Delete(ranges),
|
|
EditOrigin::UserInitiated,
|
|
selection_model,
|
|
ctx,
|
|
);
|
|
},
|
|
ctx,
|
|
);
|
|
self.validate(ctx);
|
|
}
|
|
|
|
fn command_clipboard_content(
|
|
&self,
|
|
start: CharOffset,
|
|
end: CharOffset,
|
|
ctx: &AppContext,
|
|
) -> ClipboardContent {
|
|
let mut clipboard = self.read_text_as_clipboard_content(start + 1..end, ctx);
|
|
let Some(source) = self.mermaid_block_source(start, ctx) else {
|
|
return clipboard;
|
|
};
|
|
if Self::editable_markdown_mermaid_enabled() {
|
|
clipboard.plain_text = format!("```mermaid\n{source}\n```");
|
|
}
|
|
let Some(image_html) = render_mermaid_clipboard_html(&source) else {
|
|
return clipboard;
|
|
};
|
|
clipboard.html = Some(match clipboard.html {
|
|
Some(existing_html) => format!("{existing_html}{image_html}"),
|
|
None => image_html,
|
|
});
|
|
clipboard
|
|
}
|
|
|
|
fn mermaid_block_source(&self, block_start: CharOffset, ctx: &AppContext) -> Option<String> {
|
|
if !matches!(
|
|
self.content
|
|
.as_ref(ctx)
|
|
.block_type_at_point(block_start + 1),
|
|
BlockType::Text(BufferBlockStyle::CodeBlock {
|
|
code_block_type: CodeBlockType::Mermaid,
|
|
})
|
|
) {
|
|
return None;
|
|
}
|
|
let end = self
|
|
.child_models
|
|
.models
|
|
.get(&block_start)?
|
|
.end_offset(ctx)?;
|
|
Some(
|
|
self.content
|
|
.as_ref(ctx)
|
|
.text_in_range(block_start + 1..end)
|
|
.into_string(),
|
|
)
|
|
}
|
|
|
|
/// Returns the font size at the current cursor location.
|
|
/// We use this to report cursor information to the OS.
|
|
pub fn cursor_font_size<C: ModelAsRef>(&self, ctx: &C) -> f32 {
|
|
let styles = self.render_state.as_ref(ctx).styles();
|
|
match &self.active_block_type {
|
|
BlockType::Item(_) => styles.base_text.font_size,
|
|
BlockType::Text(block_style) => styles.paragraph_styles(block_style).font_size,
|
|
}
|
|
}
|
|
|
|
/// Accessibility content for toggling an inline style.
|
|
pub fn style_toggle_a11y(&self, style: BufferTextStyle) -> ActionAccessibilityContent {
|
|
let action = if self.is_style_active(style) {
|
|
"off"
|
|
} else {
|
|
"on"
|
|
};
|
|
let text = format!("{style:?} {action}");
|
|
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
|
|
text,
|
|
WarpA11yRole::UserAction,
|
|
))
|
|
}
|
|
|
|
/// Returns the previous character before the character at current cursor location,
|
|
/// in any text block other than code blocks.
|
|
pub fn prev_char_in_non_code_block(&self, ctx: &AppContext) -> Option<char> {
|
|
if let BlockType::Text(BufferBlockStyle::CodeBlock { .. }) = self.active_block_type {
|
|
return None;
|
|
} else if self
|
|
.selection_model
|
|
.as_ref(ctx)
|
|
.first_selection_is_single_cursor()
|
|
{
|
|
return self.content.read(ctx, |content, _| {
|
|
let first_selection_head = self.selection_model.as_ref(ctx).first_selection_head();
|
|
match content
|
|
.text_in_range(first_selection_head - CharOffset::from(1)..first_selection_head)
|
|
.as_str()
|
|
.chars()
|
|
.next()
|
|
{
|
|
Some(c) => Some(c),
|
|
None => Some('\n'),
|
|
}
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Apply a vector of diffs on the current buffer by working with raw markdown content.
|
|
/// This gets the current markdown, applies the diffs, and then resets the editor with the new markdown.
|
|
pub fn apply_diffs(
|
|
&mut self,
|
|
diffs: Vec<ai::diff_validation::DiffDelta>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
// Get current markdown content
|
|
let current_markdown = self.markdown(ctx);
|
|
|
|
let lines: Vec<&str> = current_markdown.lines().collect();
|
|
let mut result_lines = Vec::new();
|
|
let mut current_line = 0;
|
|
|
|
for delta in &diffs {
|
|
// Convert 1-indexed line ranges to 0-indexed
|
|
let start_line = delta.replacement_line_range.start.saturating_sub(1);
|
|
let end_line = delta.replacement_line_range.end.saturating_sub(1);
|
|
|
|
// Add lines before this delta
|
|
while current_line < start_line {
|
|
if current_line < lines.len() {
|
|
result_lines.push(lines[current_line]);
|
|
}
|
|
current_line += 1;
|
|
}
|
|
|
|
// Skip the lines being replaced
|
|
current_line = end_line;
|
|
|
|
// Add the insertion content
|
|
for line in delta.insertion.lines() {
|
|
result_lines.push(line);
|
|
}
|
|
}
|
|
|
|
// Add any remaining lines
|
|
while current_line < lines.len() {
|
|
result_lines.push(lines[current_line]);
|
|
current_line += 1;
|
|
}
|
|
|
|
let new_markdown = result_lines.join("\n");
|
|
|
|
// Reset the editor with the new markdown content
|
|
self.reset_with_markdown(&new_markdown, ctx);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum RichTextEditorModelEvent {
|
|
/// The active styles at the cursor/selection have changed.
|
|
ActiveStylesChanged {
|
|
/// The text styles active at the cursor position.
|
|
cursor_text_styles: TextStylesWithMetadata,
|
|
/// The text styles active over the whole selection. This will differ
|
|
/// from `cursor_text_styles` if the selected range is not all formatted
|
|
/// the same way. A given style is only active if it applies to the entire
|
|
/// selection.
|
|
selection_text_styles: TextStylesWithMetadata,
|
|
block_type: BlockType,
|
|
},
|
|
ContentChanged(EditOrigin),
|
|
/// The user switched selection modes.
|
|
SwitchedSelectionMode {
|
|
new_mode: TelemetrySelectionMode,
|
|
},
|
|
}
|
|
|
|
impl Entity for NotebooksEditorModel {
|
|
type Event = RichTextEditorModelEvent;
|
|
}
|
|
|
|
impl CoreEditorModel for NotebooksEditorModel {
|
|
type T = NotebooksEditorModel;
|
|
|
|
fn content(&self) -> &ModelHandle<Buffer> {
|
|
&self.content
|
|
}
|
|
|
|
fn buffer_selection_model(&self) -> &ModelHandle<BufferSelectionModel> {
|
|
&self.selection_model
|
|
}
|
|
|
|
fn selection_model(&self) -> &ModelHandle<SelectionModel> {
|
|
&self.selection
|
|
}
|
|
|
|
fn render_state(&self) -> &ModelHandle<RenderState> {
|
|
&self.render_state
|
|
}
|
|
|
|
fn backspace(&mut self, ctx: &mut ModelContext<Self::T>) {
|
|
match self.single_selected_command_range(ctx) {
|
|
SelectedCommandResult::Single { start, end } => {
|
|
self.delete_selected_command_range(start, end, false, ctx);
|
|
return;
|
|
}
|
|
// Do not allow editing if multiple commands are selected.
|
|
SelectedCommandResult::Multiple => return,
|
|
SelectedCommandResult::None => {
|
|
if let Some(ranges) = self.maybe_rendered_mermaid_deletion_ranges(
|
|
TextDirection::Backwards,
|
|
TextUnit::Character,
|
|
ctx,
|
|
) {
|
|
self.delete_ranges(ranges, false, ctx);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
let selection_model = self.buffer_selection_model().clone();
|
|
// Edit the internal content model.
|
|
self.update_content(
|
|
|mut content, ctx| {
|
|
content.apply_edit(
|
|
BufferEditAction::Backspace,
|
|
EditOrigin::UserInitiated,
|
|
selection_model,
|
|
ctx,
|
|
)
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
self.validate(ctx);
|
|
}
|
|
|
|
/// Validate the model state. For performance, this only runs in local builds.
|
|
fn validate(&self, _ctx: &impl ModelAsRef) {
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
self.selection_model.as_ref(_ctx).validate_buffer(_ctx);
|
|
log::trace!("Validated content model");
|
|
}
|
|
}
|
|
|
|
fn active_text_style(&self) -> TextStyles {
|
|
self.active_text_style
|
|
}
|
|
|
|
fn insert(&mut self, text: &str, origin: EditOrigin, ctx: &mut ModelContext<Self::T>) {
|
|
let selection_model = self.buffer_selection_model().clone();
|
|
// Edit the internal content model.
|
|
self.update_content(
|
|
|mut content, ctx| {
|
|
// Markdown triggers should only be active when the selection is a cursor in styleable
|
|
// text. Check this _before_ inserting, because the insertion will always result in a
|
|
// single cursor.
|
|
let markdown_shortcuts_active = content
|
|
.buffer()
|
|
.can_format_at_cursor(selection_model.clone(), ctx);
|
|
|
|
content.apply_edit(
|
|
BufferEditAction::Insert {
|
|
text,
|
|
style: self.active_text_style,
|
|
override_text_style: None,
|
|
},
|
|
origin,
|
|
selection_model.clone(),
|
|
ctx,
|
|
);
|
|
|
|
if markdown_shortcuts_active {
|
|
let triggered_markdown_shortcut = NotebooksEditorModel::block_markdown_shortcut(
|
|
content.buffer(),
|
|
selection_model.clone(),
|
|
ctx,
|
|
text,
|
|
);
|
|
|
|
if let Some(action) = triggered_markdown_shortcut {
|
|
content.apply_edit(action, EditOrigin::UserTyped, selection_model, ctx);
|
|
} else if matches!(text, "*" | "_" | ")" | "`" | "~") {
|
|
self.inline_markdown_shortcut(content, selection_model, ctx);
|
|
}
|
|
}
|
|
},
|
|
ctx,
|
|
);
|
|
self.validate(ctx);
|
|
}
|
|
}
|
|
|
|
impl RichTextEditorModel for NotebooksEditorModel {
|
|
fn delete(
|
|
&mut self,
|
|
direction: TextDirection,
|
|
unit: TextUnit,
|
|
cut: bool,
|
|
ctx: &mut ModelContext<Self::T>,
|
|
) {
|
|
match self.single_selected_command_range(ctx) {
|
|
SelectedCommandResult::Single { start, end } => {
|
|
self.delete_selected_command_range(start, end, cut, ctx);
|
|
}
|
|
SelectedCommandResult::Multiple => (),
|
|
SelectedCommandResult::None => {
|
|
if let Some(ranges) =
|
|
self.maybe_rendered_mermaid_deletion_ranges(direction, unit.clone(), ctx)
|
|
{
|
|
self.delete_ranges(ranges, cut, ctx);
|
|
} else {
|
|
self.delete_internal(
|
|
direction,
|
|
unit,
|
|
cut,
|
|
move |buffer, selection_model, override_range, ctx| {
|
|
let buffer = buffer.as_ref(ctx);
|
|
let ranges = match override_range {
|
|
Some(range) => range,
|
|
None => selection_model.as_ref(ctx).selections_to_offset_ranges(),
|
|
};
|
|
|
|
let content = ClipboardContent {
|
|
plain_text: buffer.text_in_ranges_with_expanded_embedded_items(
|
|
ranges.clone(),
|
|
ctx,
|
|
),
|
|
html: buffer.ranges_as_html(ranges.clone(), ctx),
|
|
..Default::default()
|
|
};
|
|
ctx.clipboard().write(content);
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result for checking the currently-selected command block.
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum SelectedCommandResult {
|
|
/// A single command block is selected.
|
|
Single {
|
|
/// The offset of the command block's marker.
|
|
start: CharOffset,
|
|
/// The offset of the end of the command block.
|
|
end: CharOffset,
|
|
},
|
|
/// No command blocks are selected.
|
|
None,
|
|
/// More than one command block is selected.
|
|
Multiple,
|
|
}
|
|
|
|
/// Text unit for by-word navigation with the current word boundary policy.
|
|
pub fn word_unit(ctx: &AppContext) -> TextUnit {
|
|
TextUnit::Word(SemanticSelection::as_ref(ctx).word_boundary_policy())
|
|
}
|
|
|
|
/// Container for logical editor models that are nested within a notebooks editor model.
|
|
///
|
|
/// Sub-models include:
|
|
/// * [`NotebookCommand`] for command/code blocks
|
|
/// * (Soon) embedded workflows
|
|
pub struct ChildModels {
|
|
/// Sub-models mapped by their starting offset as of the last cycle of text layout. This
|
|
/// prevents flicker when updating the content model, as the [`Anchor`]s within the command
|
|
/// models will update before the [`RenderState`] does.
|
|
models: HashMap<CharOffset, Box<dyn ChildModelHandle>>,
|
|
}
|
|
|
|
/// Handle to a logical sub-model of the notebook editor.
|
|
pub trait ChildModelHandle {
|
|
/// The starting offset for the block that this model manages (for example, the start of a code
|
|
/// block).
|
|
fn start_offset(&self, app: &AppContext) -> Option<CharOffset>;
|
|
|
|
/// The end offset for the block that this model manages (for example, the end of a code
|
|
/// block).
|
|
fn end_offset(&self, app: &AppContext) -> Option<CharOffset>;
|
|
|
|
/// Whether the model is selected.
|
|
fn selected(&self, app: &AppContext) -> bool;
|
|
|
|
/// Whether the represented child model is selectable.
|
|
fn selectable(&self, app: &AppContext) -> bool;
|
|
|
|
/// Returns the workflow representation of this child model (if applies).
|
|
fn executable_workflow(&self, app: &AppContext) -> Option<NotebookWorkflow>;
|
|
|
|
/// Returns the plain text of the underlying executable command for this child model (if
|
|
/// applies).
|
|
fn executable_command<'a>(&'a self, app: &'a AppContext) -> Option<Cow<'a, str>>;
|
|
|
|
/// Update the selection state of the child model, returning its previous selection state.
|
|
fn set_selected(&self, selected: bool, ctx: &mut AppContext) -> bool;
|
|
|
|
/// Coerce to [`Any`] for downcasting.
|
|
fn as_any(&self) -> &dyn Any;
|
|
|
|
/// Clone this handle. Because this is a handle, not the model itself, it's expected to be cheap.
|
|
fn clone_boxed(&self) -> Box<dyn ChildModelHandle>;
|
|
}
|
|
|
|
impl ChildModels {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
models: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub fn selectable_offsets(&self, app: &AppContext) -> Vec<CharOffset> {
|
|
self.models
|
|
.iter()
|
|
.filter_map(|(offset, model)| {
|
|
if model.selectable(app) {
|
|
Some(*offset)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.sorted()
|
|
.collect()
|
|
}
|
|
|
|
/// Retrieve the child model of the given type backing the block at `offset`.
|
|
///
|
|
/// Returns `None` if:
|
|
/// * There is not a block of the expected type at `offset`.
|
|
/// * The child model state is out of sync with the underlying buffer.
|
|
pub fn model_at<T: 'static>(&self, offset: CharOffset) -> Option<ModelHandle<T>> {
|
|
Some(
|
|
self.models
|
|
.get(&offset)?
|
|
.as_any()
|
|
.downcast_ref::<ModelHandle<T>>()?
|
|
.clone(),
|
|
)
|
|
}
|
|
|
|
/// Iterate over all child models of the given type.
|
|
pub fn models<'a, T: Entity>(
|
|
&'a self,
|
|
ctx: &'a AppContext,
|
|
) -> impl Iterator<Item = &'a T> + 'a {
|
|
self.model_handles().map(|handle| handle.as_ref(ctx))
|
|
}
|
|
|
|
/// Iterates over all child model handles of the given type.
|
|
pub fn model_handles<T: Entity>(&self) -> impl Iterator<Item = ModelHandle<T>> + '_ {
|
|
self.models
|
|
.values()
|
|
.filter_map(|handle| handle.as_any().downcast_ref())
|
|
.cloned()
|
|
}
|
|
|
|
/// Update the sub-model state with [`NotebookCommand`] models for every runnable command
|
|
/// in the buffer. This should be called after text layout completes, so that the offsets of
|
|
/// each block line up between the render and content models.
|
|
pub fn update<T: Entity>(
|
|
&mut self,
|
|
interaction_state: ModelHandle<InteractionStateModel>,
|
|
content: ModelHandle<Buffer>,
|
|
selection_model: ModelHandle<BufferSelectionModel>,
|
|
rte_window_id: WindowId,
|
|
ctx: &mut ModelContext<T>,
|
|
) {
|
|
// Resolve each existing model to its current offsets in the buffer, filtering out models
|
|
// whose anchors have been deleted in the process.
|
|
let mut existing_models: HashMap<_, _> = self
|
|
.models
|
|
.drain()
|
|
.filter_map(|(_, handle)| {
|
|
let start = handle.start_offset(ctx)?;
|
|
let end = handle.end_offset(ctx)?;
|
|
Some(((start, end), handle))
|
|
})
|
|
.collect();
|
|
|
|
// Ensure there's a model for every code block in the buffer.
|
|
// - If it's an existing block, there will be an entry in `existing_models`
|
|
// - If it's a new block, we'll create a new model for it
|
|
// - If a block were unstyled, its anchors may still be valid, but it won't be in the new
|
|
// outline, so the existing model handle will be dropped at the end of the method.
|
|
let mut to_add = vec![];
|
|
let mut new_embedded_item = vec![];
|
|
let mut reset_selection = vec![];
|
|
|
|
for outline in content.as_ref(ctx).outline_blocks() {
|
|
match outline.block_type {
|
|
BlockType::Text(BufferBlockStyle::CodeBlock { .. }) => {
|
|
match existing_models.remove(&(outline.start, outline.end)) {
|
|
Some(existing_model)
|
|
if existing_model.as_any().is::<ModelHandle<NotebookCommand>>() =>
|
|
{
|
|
log::trace!(
|
|
"Reusing existing NotebookCommand model at {}..{}",
|
|
outline.start,
|
|
outline.end
|
|
);
|
|
|
|
if !existing_model.selectable(ctx) && existing_model.selected(ctx) {
|
|
reset_selection.push((outline.start, existing_model));
|
|
} else {
|
|
self.models.insert(outline.start, existing_model);
|
|
}
|
|
}
|
|
_ => to_add.push(outline),
|
|
}
|
|
}
|
|
BlockType::Item(BufferBlockItem::Embedded { item }) => {
|
|
match existing_models.remove(&(outline.start, outline.end)) {
|
|
Some(existing_model)
|
|
if existing_model.as_any().is::<ModelHandle<NotebookEmbed>>() =>
|
|
{
|
|
log::trace!("Reusing existing EmbeddedItem model at {}", outline.start);
|
|
|
|
if !existing_model.selectable(ctx) && existing_model.selected(ctx) {
|
|
reset_selection.push((outline.start, existing_model));
|
|
} else {
|
|
self.models.insert(outline.start, existing_model);
|
|
}
|
|
}
|
|
_ => new_embedded_item.push((item.hashed_id().to_string(), outline.start)),
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
// We have to add new models in a separate pass, because creating anchors requires a
|
|
// mutable borrow of `content`, while the `outline_blocks` iterator already immutably
|
|
// borrows it.
|
|
self.models
|
|
.reserve(to_add.len() + new_embedded_item.len() + reset_selection.len());
|
|
|
|
for (model_start, model) in reset_selection {
|
|
model.set_selected(false, ctx);
|
|
self.models.insert(model_start, model);
|
|
}
|
|
|
|
for outline in to_add {
|
|
log::debug!(
|
|
"Adding NotebookCommand model at {}..{}",
|
|
outline.start,
|
|
outline.end
|
|
);
|
|
let new_model = ctx.add_model(|ctx| {
|
|
NotebookCommand::new(
|
|
outline.start,
|
|
outline.end,
|
|
interaction_state.clone(),
|
|
content.clone(),
|
|
selection_model.clone(),
|
|
rte_window_id,
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
self.models.insert(outline.start, Box::new(new_model));
|
|
}
|
|
|
|
for (hashed_id, start_offset) in new_embedded_item {
|
|
log::debug!("Adding EmbeddedItem model at {start_offset}");
|
|
let new_model: ModelHandle<_> = ctx.add_model(|ctx| {
|
|
NotebookEmbed::new(
|
|
start_offset,
|
|
hashed_id,
|
|
content.clone(),
|
|
selection_model.clone(),
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
self.models.insert(start_offset, Box::new(new_model));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if a content string is fully a valid URL.
|
|
fn is_valid_url(content: &str) -> Option<String> {
|
|
match Url::parse(content) {
|
|
Ok(_) => Some(content.to_string()),
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
fn notebook_tab_indentation(block_style: &BufferBlockStyle, shift: bool) -> IndentBehavior {
|
|
if !shift {
|
|
match block_style {
|
|
BufferBlockStyle::UnorderedList {
|
|
indent_level: ListIndentLevel::Three,
|
|
}
|
|
| BufferBlockStyle::OrderedList {
|
|
indent_level: ListIndentLevel::Three,
|
|
..
|
|
} => IndentBehavior::Ignore,
|
|
BufferBlockStyle::OrderedList {
|
|
indent_level,
|
|
number,
|
|
} => IndentBehavior::Restyle(BufferBlockStyle::OrderedList {
|
|
indent_level: indent_level.shift_right(),
|
|
number: *number,
|
|
}),
|
|
BufferBlockStyle::UnorderedList { indent_level } => {
|
|
IndentBehavior::Restyle(BufferBlockStyle::UnorderedList {
|
|
indent_level: indent_level.shift_right(),
|
|
})
|
|
}
|
|
BufferBlockStyle::TaskList {
|
|
indent_level,
|
|
complete,
|
|
} => IndentBehavior::Restyle(BufferBlockStyle::TaskList {
|
|
indent_level: indent_level.shift_right(),
|
|
complete: *complete,
|
|
}),
|
|
BufferBlockStyle::CodeBlock { .. } => IndentBehavior::TabIndent(IndentUnit::Space(4)),
|
|
_ => IndentBehavior::Ignore,
|
|
}
|
|
} else {
|
|
match block_style {
|
|
BufferBlockStyle::UnorderedList {
|
|
indent_level: ListIndentLevel::One,
|
|
}
|
|
| BufferBlockStyle::OrderedList {
|
|
indent_level: ListIndentLevel::One,
|
|
..
|
|
} => IndentBehavior::Ignore,
|
|
BufferBlockStyle::OrderedList {
|
|
indent_level,
|
|
number,
|
|
} => IndentBehavior::Restyle(BufferBlockStyle::OrderedList {
|
|
indent_level: indent_level.shift_left(),
|
|
number: *number,
|
|
}),
|
|
BufferBlockStyle::UnorderedList { indent_level } => {
|
|
IndentBehavior::Restyle(BufferBlockStyle::UnorderedList {
|
|
indent_level: indent_level.shift_left(),
|
|
})
|
|
}
|
|
BufferBlockStyle::TaskList {
|
|
indent_level,
|
|
complete,
|
|
} => IndentBehavior::Restyle(BufferBlockStyle::TaskList {
|
|
indent_level: indent_level.shift_left(),
|
|
complete: *complete,
|
|
}),
|
|
BufferBlockStyle::CodeBlock { .. } => IndentBehavior::TabIndent(IndentUnit::Space(4)),
|
|
_ => IndentBehavior::Ignore,
|
|
}
|
|
}
|
|
}
|