Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_editor::content::text::BufferBlockItem;
|
||||
use warpui::{
|
||||
elements::{
|
||||
AnchorPair, Border, Container, CornerRadius, MouseStateHandle, OffsetPositioning,
|
||||
OffsetType, PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Stack,
|
||||
XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonTooltipPosition,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, SingletonEntity, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{model::persistence::CloudModel, ObjectIdType, Space},
|
||||
drive::CloudObjectTypeAndId,
|
||||
menu::{self, Menu, MenuItemFields},
|
||||
notebooks::telemetry::EmbeddedObjectInfo,
|
||||
search::notebook_embedding::{
|
||||
searcher::EmbeddingSearchItemAction,
|
||||
view::{EmbeddingSearchEvent, EmbeddingSearchMenu},
|
||||
},
|
||||
server::ids::SyncId,
|
||||
themes::theme::Fill,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
};
|
||||
|
||||
use super::{
|
||||
embedded_item::EmbeddedWorkflow,
|
||||
view::{EditorViewAction, EditorViewEvent, RichTextEditorView},
|
||||
BlockType,
|
||||
};
|
||||
|
||||
/// The saved position ID for the block insertion button.
|
||||
const BLOCK_INSERT_BUTTON_ID: &str = "notebook_block_insertion_button";
|
||||
|
||||
/// Where the block insertion menu was triggered from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BlockInsertionSource {
|
||||
AtCursor,
|
||||
BlockInsertionButton,
|
||||
}
|
||||
|
||||
/// Editor view state related to the block insertion menu.
|
||||
pub struct BlockInsertionMenuState {
|
||||
// If the menu is closed, this will be None.
|
||||
pub open_at_source: Option<BlockInsertionSource>,
|
||||
button_state: MouseStateHandle,
|
||||
// Whether the embedded object search menu is open.
|
||||
pub embedded_object_search_open: bool,
|
||||
/// The embedded object search menu, lazily created when embedded objects are enabled.
|
||||
embedded_object_search: Option<ViewHandle<EmbeddingSearchMenu>>,
|
||||
pub menu: ViewHandle<Menu<EditorViewAction>>,
|
||||
}
|
||||
|
||||
impl BlockInsertionMenuState {
|
||||
pub fn new(ctx: &mut ViewContext<RichTextEditorView>, embedded_objects_enabled: bool) -> Self {
|
||||
let menu =
|
||||
ctx.add_typed_action_view(|ctx| Self::create_menu(embedded_objects_enabled, ctx));
|
||||
|
||||
ctx.subscribe_to_view(&menu, RichTextEditorView::handle_block_insertion_menu_event);
|
||||
|
||||
let embedded_object_search = if embedded_objects_enabled {
|
||||
let embedded_object_search = ctx.add_typed_action_view(EmbeddingSearchMenu::new);
|
||||
ctx.subscribe_to_view(
|
||||
&embedded_object_search,
|
||||
RichTextEditorView::handle_embedded_object_search_menu_event,
|
||||
);
|
||||
Some(embedded_object_search)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
open_at_source: None,
|
||||
button_state: Default::default(),
|
||||
embedded_object_search_open: false,
|
||||
embedded_object_search,
|
||||
menu,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_menu(
|
||||
embedded_objects_enabled: bool,
|
||||
ctx: &mut ViewContext<Menu<EditorViewAction>>,
|
||||
) -> Menu<EditorViewAction> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let mut menu = Menu::new().prevent_interaction_with_other_elements();
|
||||
|
||||
for block_type in BlockType::code_block_types() {
|
||||
menu.add_item(
|
||||
MenuItemFields::new(block_type.label())
|
||||
.with_icon(block_type.icon())
|
||||
.with_on_select_action(EditorViewAction::InsertBlock(
|
||||
warp_editor::content::text::BlockType::Text(block_type.into()),
|
||||
))
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
|
||||
if embedded_objects_enabled {
|
||||
menu.add_item(
|
||||
MenuItemFields::new("Embed")
|
||||
.with_icon(Icon::EmbedBlock)
|
||||
.with_on_select_action(EditorViewAction::OpenEmbeddedObjectSearch)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
|
||||
for block_type in BlockType::text_block_types() {
|
||||
let mut item_fields = MenuItemFields::new(block_type.label())
|
||||
.with_icon(block_type.icon())
|
||||
.with_on_select_action(EditorViewAction::InsertBlock(
|
||||
warp_editor::content::text::BlockType::Text(block_type.into()),
|
||||
));
|
||||
if let Some(icon_fill) = block_type.icon_color(appearance) {
|
||||
item_fields = item_fields.with_override_icon_color(icon_fill);
|
||||
}
|
||||
menu.add_item(item_fields.into_item());
|
||||
}
|
||||
|
||||
menu.add_item(
|
||||
MenuItemFields::new("Divider")
|
||||
.with_icon(Icon::HorizontalRuleBlock)
|
||||
.with_on_select_action(EditorViewAction::InsertBlock(
|
||||
warp_editor::content::text::BlockType::Item(BufferBlockItem::HorizontalRule),
|
||||
))
|
||||
.with_override_icon_color(Fill::Solid(appearance.theme().ui_warning_color()))
|
||||
.into_item(),
|
||||
);
|
||||
|
||||
menu
|
||||
}
|
||||
|
||||
pub fn reset_selection(&mut self, ctx: &mut AppContext) {
|
||||
self.menu.update(ctx, |menu, ctx| {
|
||||
menu.reset_selection(ctx);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RichTextEditorView {
|
||||
/// Open the block insertion menu.
|
||||
pub(super) fn open_block_insertion_menu(
|
||||
&mut self,
|
||||
source: BlockInsertionSource,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Reset selection if we are opening a new block insertion menu or opening
|
||||
// the menu from a different source.
|
||||
if self.insertion_menu_state.open_at_source != Some(source) {
|
||||
self.insertion_menu_state.reset_selection(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
self.insertion_menu_state.open_at_source = Some(source);
|
||||
// By default we should show the block insertion menu.
|
||||
self.insertion_menu_state.embedded_object_search_open = false;
|
||||
ctx.focus(&self.insertion_menu_state.menu);
|
||||
ctx.emit(EditorViewEvent::OpenedBlockInsertionMenu(source));
|
||||
}
|
||||
|
||||
pub(super) fn open_embedded_object_search(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(embedded_object_search) = &self.insertion_menu_state.embedded_object_search else {
|
||||
return;
|
||||
};
|
||||
self.insertion_menu_state.embedded_object_search_open = true;
|
||||
// Reset the filter state.
|
||||
embedded_object_search.update(ctx, |menu, ctx| {
|
||||
menu.reset_state(ctx);
|
||||
});
|
||||
ctx.focus(embedded_object_search);
|
||||
ctx.emit(EditorViewEvent::OpenedEmbeddedObjectSearch);
|
||||
}
|
||||
|
||||
/// Set the space containing this notebook.
|
||||
pub fn set_space(&mut self, space: Space, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(embedded_object_search) = &self.insertion_menu_state.embedded_object_search {
|
||||
embedded_object_search.update(ctx, |menu, ctx| menu.set_embedding_space(space, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the block insertion menu.
|
||||
pub(super) fn close_block_insertion_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.is_block_insertion_menu_open() {
|
||||
ctx.notify();
|
||||
}
|
||||
self.insertion_menu_state.open_at_source = None;
|
||||
self.insertion_menu_state.embedded_object_search_open = false;
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
/// Whether the block insertion menu is open.
|
||||
pub(super) fn is_block_insertion_menu_open(&self) -> bool {
|
||||
self.insertion_menu_state.open_at_source.is_some()
|
||||
}
|
||||
|
||||
fn handle_embedded_object_search_menu_event(
|
||||
&mut self,
|
||||
_handle: ViewHandle<EmbeddingSearchMenu>,
|
||||
event: &EmbeddingSearchEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
EmbeddingSearchEvent::Close => self.close_block_insertion_menu(ctx),
|
||||
EmbeddingSearchEvent::ItemSelected { payload } => match payload.as_ref() {
|
||||
EmbeddingSearchItemAction::AcceptWorkflow(id) => {
|
||||
self.insert_embedded_workflow(id, ctx)
|
||||
}
|
||||
EmbeddingSearchItemAction::AcceptNotebook(id) => {
|
||||
self.insert_embedded_notebook(id, ctx)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert an embedded workflow block at the current insertion menu source.
|
||||
fn insert_embedded_workflow(&mut self, id: &SyncId, ctx: &mut ViewContext<Self>) {
|
||||
self.insert_block(
|
||||
warp_editor::content::text::BlockType::Item(BufferBlockItem::Embedded {
|
||||
item: Arc::new(EmbeddedWorkflow::new(
|
||||
id.sqlite_uid_hash(ObjectIdType::Workflow),
|
||||
)),
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
let team_uid = CloudModel::as_ref(ctx)
|
||||
.get_workflow(id)
|
||||
.and_then(|workflow| workflow.permissions.owner.into());
|
||||
ctx.emit(EditorViewEvent::InsertedEmbeddedObject(
|
||||
EmbeddedObjectInfo::Workflow {
|
||||
workflow_id: id.into_server().map(Into::into),
|
||||
team_uid,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Insert an embedded notebook inline view at the current insertion menu source.
|
||||
fn insert_embedded_notebook(&mut self, id: &SyncId, ctx: &mut ViewContext<Self>) {
|
||||
let (title, link) = CloudModel::handle(ctx).read(ctx, |model, _| {
|
||||
let title = model
|
||||
.get_notebook(id)
|
||||
.map(|notebook| notebook.model().title.clone())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
let link = model
|
||||
.get_by_uid(&CloudObjectTypeAndId::Notebook(*id).uid())
|
||||
.and_then(|object| object.object_link());
|
||||
(title, link)
|
||||
});
|
||||
|
||||
if let Some(link) = link {
|
||||
self.insert_embedded_notebook_view(title, link, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback for events on the block insertion menu.
|
||||
fn handle_block_insertion_menu_event(
|
||||
&mut self,
|
||||
_menu: ViewHandle<Menu<EditorViewAction>>,
|
||||
event: &menu::Event,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
menu::Event::ItemSelected | menu::Event::ItemHovered => (),
|
||||
menu::Event::Close { via_select_item } => {
|
||||
// Don't close the block insertion menu if the embedded object
|
||||
// search menu is open. Handle the close event emitted from
|
||||
// embedded object search menu instead.
|
||||
if self.insertion_menu_state.embedded_object_search_open {
|
||||
return;
|
||||
}
|
||||
self.close_block_insertion_menu(ctx);
|
||||
if !*via_select_item {
|
||||
ctx.focus_self()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders controls for the block insertion menu.
|
||||
pub(super) fn render_block_insertion_menu(&self, stack: &mut Stack, app: &AppContext) {
|
||||
if self.disable_block_insertion_menu() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.can_edit_app(app) {
|
||||
self.render_button(stack, app);
|
||||
}
|
||||
|
||||
if let Some(source) = self.insertion_menu_state.open_at_source {
|
||||
self.render_menu(source, stack, app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a button that opens the block insertion menu when clicked.
|
||||
fn render_button(&self, stack: &mut Stack, app: &AppContext) {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
let button = icon_button(
|
||||
appearance,
|
||||
Icon::Plus,
|
||||
self.insertion_menu_state.open_at_source
|
||||
== Some(BlockInsertionSource::BlockInsertionButton),
|
||||
self.insertion_menu_state.button_state.clone(),
|
||||
)
|
||||
.with_active_styles(UiComponentStyles {
|
||||
background: Some(appearance.theme().surface_2().into()),
|
||||
border_color: Some(appearance.theme().surface_3().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Insert block".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
// Position the tooltip above the insertion button to ensure they don't overlap if the
|
||||
// button is towards the bottom of the screen.
|
||||
.with_tooltip_position(ButtonTooltipPosition::Above)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(EditorViewAction::OpenBlockInsertionMenu))
|
||||
.finish();
|
||||
|
||||
let render_state = self.model.as_ref(app).render_state();
|
||||
let hovered_block_id = render_state
|
||||
.as_ref(app)
|
||||
.saved_positions()
|
||||
.hovered_block_start();
|
||||
|
||||
stack.add_positioned_child(
|
||||
SavePosition::new(button, BLOCK_INSERT_BUTTON_ID).finish(),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&hovered_block_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(-4.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Right),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
hovered_block_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(YAxisAnchor::Middle, YAxisAnchor::Middle),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a menu for inserting new kinds of blocks.
|
||||
fn render_menu(&self, source: BlockInsertionSource, stack: &mut Stack, app: &AppContext) {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let render_state = self.model.as_ref(app).render_state.as_ref(app);
|
||||
|
||||
let (container, bounds) = if !self.insertion_menu_state.embedded_object_search_open {
|
||||
let menu = ChildView::new(&self.insertion_menu_state.menu).finish();
|
||||
(
|
||||
Container::new(menu)
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish(),
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
)
|
||||
} else if let Some(embedded_object_search) =
|
||||
&self.insertion_menu_state.embedded_object_search
|
||||
{
|
||||
(
|
||||
ChildView::new(embedded_object_search).finish(),
|
||||
// Embedded object search menu is not bounded by the editor.
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
)
|
||||
} else {
|
||||
// Embedded object search is open but no menu exists - shouldn't happen.
|
||||
return;
|
||||
};
|
||||
|
||||
let positioning = match source {
|
||||
BlockInsertionSource::BlockInsertionButton => OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
BLOCK_INSERT_BUTTON_ID,
|
||||
bounds,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
BLOCK_INSERT_BUTTON_ID,
|
||||
bounds,
|
||||
OffsetType::Pixel(4.),
|
||||
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
|
||||
),
|
||||
),
|
||||
BlockInsertionSource::AtCursor => {
|
||||
let cursor_position = render_state.saved_positions().cursor_id();
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&cursor_position,
|
||||
bounds,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&cursor_position,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(4.),
|
||||
// TODO: Decide if this should be above or below the cursor based
|
||||
// on its location within the viewport.
|
||||
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
stack.add_positioned_overlay_child(container, positioning);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
use std::{collections::HashMap, ops::Range, sync::Arc};
|
||||
|
||||
use itertools::Itertools;
|
||||
use markdown_parser::html_parser::WARP_EMBED_ATTRIBUTE_NAME;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use serde_yaml::Mapping;
|
||||
use string_offset::ByteOffset;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_editor::{
|
||||
content::{markdown::MarkdownStyle, text::TextStylesWithMetadata},
|
||||
editor::EmbeddedItemModel,
|
||||
extract_block,
|
||||
render::{
|
||||
element::{CursorData, CursorDisplayType, RenderContext, RenderableBlock},
|
||||
layout::TextLayout,
|
||||
model::{
|
||||
viewport::ViewportItem, BlockItem, BlockSpacing, BrokenBlockEmbedding, EmbeddedItem,
|
||||
EmbeddedItemHTMLRepresentation, EmbeddedItemRichFormat, LaidOutEmbeddedItem,
|
||||
ParagraphStyles, RenderState, EMBEDDED_ITEM_FIRST_LINE_HEIGHT,
|
||||
},
|
||||
BLOCK_FOOTER_HEIGHT,
|
||||
},
|
||||
};
|
||||
use warpui::{
|
||||
elements::{Border, Empty},
|
||||
SingletonEntity,
|
||||
};
|
||||
use warpui::{
|
||||
elements::{ConstrainedBox, CornerRadius, Margin, Padding, Radius},
|
||||
text_layout::TextFrame,
|
||||
units::{IntoPixels, Pixels},
|
||||
AppContext, Element, LayoutContext, SizeConstraint,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{model::persistence::CloudModel, CloudObject},
|
||||
drive::{cloud_object_styling::warp_drive_icon_color, DriveObjectType},
|
||||
server::ids::{HashableId, ToServerId},
|
||||
ui_components::icons::Icon,
|
||||
workflows::{workflow::Workflow, CloudWorkflow, WorkflowId},
|
||||
};
|
||||
|
||||
// Spacing for the embedded workflow card.
|
||||
const EMBED_WORKFLOW_SPACING: BlockSpacing = BlockSpacing {
|
||||
margin: Margin::uniform(0.)
|
||||
.with_top(8.)
|
||||
.with_left(4.)
|
||||
.with_bottom(8.)
|
||||
.with_right(16.),
|
||||
padding: Padding::uniform(8.)
|
||||
.with_left(16.)
|
||||
.with_top(16.)
|
||||
// Reserve space for the buttons.
|
||||
.with_bottom(BLOCK_FOOTER_HEIGHT),
|
||||
};
|
||||
|
||||
// Spacing for the text sections (e.g. title, command) within the workflow card.
|
||||
const EMBED_WORKFLOW_TEXT_SPACING: BlockSpacing = BlockSpacing {
|
||||
margin: Margin::uniform(0.)
|
||||
.with_top(8.)
|
||||
.with_left(4.)
|
||||
.with_bottom(8.)
|
||||
.with_right(16.),
|
||||
padding: Padding::uniform(8.)
|
||||
.with_left(40.)
|
||||
.with_top(16.)
|
||||
// Reserve space for the buttons.
|
||||
.with_bottom(BLOCK_FOOTER_HEIGHT),
|
||||
};
|
||||
const TITLE_TO_DESCRIPTION_PADDING: f32 = 4.;
|
||||
const DESCRIPTION_TO_COMMAND_PADDING: f32 = 8.;
|
||||
|
||||
const WORKFLOW_ICON_SIZE: f32 = 16.;
|
||||
const WORKFLOW_TEXT_PADDING: f32 = 24.;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EmbeddedWorkflow {
|
||||
hashed_id: String,
|
||||
syntax_highlights: Vec<(Range<ByteOffset>, ColorU)>,
|
||||
}
|
||||
|
||||
impl EmbeddedWorkflow {
|
||||
pub fn new(hashed_id: String) -> Self {
|
||||
Self {
|
||||
hashed_id,
|
||||
syntax_highlights: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_syntax_highlighting(
|
||||
mut self,
|
||||
syntax_highlights: Vec<(Range<ByteOffset>, ColorU)>,
|
||||
) -> Self {
|
||||
self.syntax_highlights = syntax_highlights;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn command_text_frames(
|
||||
&self,
|
||||
command: String,
|
||||
command_text_style: &ParagraphStyles,
|
||||
text_layout: &TextLayout,
|
||||
) -> Vec<Arc<TextFrame>> {
|
||||
// Index of the active syntax styling.
|
||||
let mut syntax_style_index = 0;
|
||||
|
||||
// ByteOffset before the current line.
|
||||
let mut byteoffset_before_line = ByteOffset::zero();
|
||||
let default_command_style =
|
||||
text_layout.style_and_font(command_text_style, &TextStylesWithMetadata::default());
|
||||
let mut text_frames = vec![];
|
||||
|
||||
for line in command.lines() {
|
||||
let mut style_runs = Vec::new();
|
||||
|
||||
let total_line_byteoffset = ByteOffset::from(line.len());
|
||||
let mut byteoffset_from_line_start = ByteOffset::zero();
|
||||
|
||||
// Mapping from byte to character offset.
|
||||
let byte_to_charoffset_mapping =
|
||||
line.char_indices().map(|(index, _)| index).collect_vec();
|
||||
|
||||
while let Some((styling_range, color)) = self.syntax_highlights.get(syntax_style_index)
|
||||
{
|
||||
// Break out of the loop if either
|
||||
// 1) the current byte offset is already past the max of the line.
|
||||
// 2) the start of the active styling range is past the max of the line.
|
||||
if byteoffset_from_line_start >= total_line_byteoffset
|
||||
|| styling_range.start >= byteoffset_before_line + total_line_byteoffset
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Total byte offset from the start of text frame.
|
||||
let byteoffset_from_frame_start =
|
||||
byteoffset_from_line_start + byteoffset_before_line;
|
||||
|
||||
// Three scenarios:
|
||||
// 1) If byte offset is before the start of the styling range, push a style run with default styling until the start of styling range.
|
||||
// 2) If byte offset is after the start and before the end of the styling range, push the style run with the active styling.
|
||||
// 3) If byte offset is after the end of the styling range, increment the active styling range index.
|
||||
byteoffset_from_line_start = if styling_range.start > byteoffset_from_frame_start {
|
||||
let new_byteoffset = styling_range.start - byteoffset_before_line;
|
||||
style_runs.push((
|
||||
byteoffset_from_line_start..new_byteoffset,
|
||||
default_command_style,
|
||||
));
|
||||
new_byteoffset
|
||||
} else if styling_range.start <= byteoffset_from_frame_start
|
||||
&& byteoffset_from_frame_start < styling_range.end
|
||||
{
|
||||
let new_byteoffset =
|
||||
(styling_range.end - byteoffset_before_line).min(total_line_byteoffset);
|
||||
let command_style = text_layout.style_and_font(
|
||||
command_text_style,
|
||||
&TextStylesWithMetadata::default().with_color(*color),
|
||||
);
|
||||
style_runs.push((byteoffset_from_line_start..new_byteoffset, command_style));
|
||||
|
||||
// Only increment the active style range index if we have consumed the entire styling range.
|
||||
if styling_range.end <= total_line_byteoffset + byteoffset_before_line {
|
||||
syntax_style_index += 1;
|
||||
}
|
||||
|
||||
new_byteoffset
|
||||
} else {
|
||||
syntax_style_index += 1;
|
||||
continue;
|
||||
};
|
||||
}
|
||||
|
||||
// If the byte offset is not past the line max, push a default style run for the remaining part
|
||||
// of the line.
|
||||
if byteoffset_from_line_start < total_line_byteoffset {
|
||||
style_runs.push((
|
||||
byteoffset_from_line_start..total_line_byteoffset,
|
||||
default_command_style,
|
||||
));
|
||||
}
|
||||
|
||||
// Translate from byte offsets to character offsets.
|
||||
let mut char_style_runs = vec![];
|
||||
for (style_range, style) in style_runs {
|
||||
let starting_char =
|
||||
match byte_to_charoffset_mapping.binary_search(&style_range.start.as_usize()) {
|
||||
Ok(num) => num,
|
||||
Err(num) => num,
|
||||
};
|
||||
|
||||
let ending_char =
|
||||
match byte_to_charoffset_mapping.binary_search(&style_range.end.as_usize()) {
|
||||
Ok(num) => num,
|
||||
Err(num) => num,
|
||||
};
|
||||
|
||||
char_style_runs.push((starting_char..ending_char, style));
|
||||
}
|
||||
|
||||
text_frames.push(text_layout.layout_text(
|
||||
line,
|
||||
command_text_style,
|
||||
&EMBED_WORKFLOW_TEXT_SPACING,
|
||||
&char_style_runs,
|
||||
));
|
||||
|
||||
// Include linebreaks into the byte offset.
|
||||
byteoffset_before_line += total_line_byteoffset + 1;
|
||||
}
|
||||
text_frames
|
||||
}
|
||||
|
||||
/// Get the backing [`CloudWorkflow`] for this embed.
|
||||
fn get_workflow<'a>(&self, app: &'a AppContext) -> Option<&'a CloudWorkflow> {
|
||||
// TODO: @ianhodge - replace the `from_hash` when we create a new API for going from
|
||||
// sqlite hash id -> uid
|
||||
let uid = WorkflowId::from_hash(&self.hashed_id).map(|id| id.to_server_id().uid())?;
|
||||
CloudModel::as_ref(app)
|
||||
.get_by_uid(&uid)
|
||||
.and_then(|object| object.as_any().downcast_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl EmbeddedItem for EmbeddedWorkflow {
|
||||
fn layout(&self, text_layout: &TextLayout, app: &AppContext) -> Box<dyn LaidOutEmbeddedItem> {
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let cloud_workflow = self.get_workflow(app);
|
||||
|
||||
let base_text_style = &text_layout.rich_text_styles().base_text;
|
||||
let width = text_layout.max_width() - EMBED_WORKFLOW_TEXT_SPACING.x_axis_offset();
|
||||
|
||||
let Some(workflow) = cloud_workflow.and_then(|workflow| {
|
||||
if !workflow.is_trashed(cloud_model) {
|
||||
Some(Into::<Workflow>::into(workflow))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) else {
|
||||
return Box::new(BrokenBlockEmbedding::new(width, base_text_style.font_size));
|
||||
};
|
||||
|
||||
let command_text_style = &text_layout.rich_text_styles().embedding_text;
|
||||
|
||||
let title_style =
|
||||
text_layout.style_and_font(base_text_style, &TextStylesWithMetadata::default());
|
||||
|
||||
let title_frame = text_layout.layout_text(
|
||||
workflow.name(),
|
||||
base_text_style,
|
||||
&EMBED_WORKFLOW_TEXT_SPACING,
|
||||
&[(0..workflow.name().chars().count(), title_style)],
|
||||
);
|
||||
|
||||
// Use placeholder style for description text.
|
||||
let description_style = text_layout.style_and_font(
|
||||
base_text_style,
|
||||
&TextStylesWithMetadata::default().for_placeholder(),
|
||||
);
|
||||
let description_frame = workflow.description().map(|description| {
|
||||
text_layout.layout_text(
|
||||
description,
|
||||
base_text_style,
|
||||
&EMBED_WORKFLOW_TEXT_SPACING,
|
||||
&[(0..description.chars().count(), description_style)],
|
||||
)
|
||||
});
|
||||
|
||||
let content_frames = self.command_text_frames(
|
||||
workflow.content().to_owned(),
|
||||
command_text_style,
|
||||
text_layout,
|
||||
);
|
||||
|
||||
let is_agent_mode_prompt =
|
||||
cloud_workflow.is_some_and(|w| w.model().data.is_agent_mode_workflow());
|
||||
|
||||
Box::new(LaidOutEmbeddedWorkflow::new(
|
||||
title_frame,
|
||||
description_frame,
|
||||
content_frames,
|
||||
width,
|
||||
is_agent_mode_prompt,
|
||||
))
|
||||
}
|
||||
|
||||
fn hashed_id(&self) -> &str {
|
||||
self.hashed_id.as_str()
|
||||
}
|
||||
|
||||
fn to_mapping(&self, style: MarkdownStyle) -> Mapping {
|
||||
let mut base = match style {
|
||||
MarkdownStyle::Internal => Default::default(),
|
||||
MarkdownStyle::Export { app_context, .. } => app_context
|
||||
.and_then(|ctx| self.get_workflow(ctx))
|
||||
.and_then(|workflow| serde_yaml::to_value(&workflow.model().data).ok())
|
||||
.and_then(|value| match value {
|
||||
serde_yaml::Value::Mapping(mapping) => Some(mapping),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
base.insert("id".into(), self.hashed_id().into());
|
||||
base
|
||||
}
|
||||
|
||||
fn to_rich_format(&self, app: &AppContext) -> EmbeddedItemRichFormat<'_> {
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let workflow = self.get_workflow(app);
|
||||
|
||||
// If the workflow is no longer accessible or is trashed, set the content to
|
||||
// an empty string. But we should still keep the HTML element formatting and
|
||||
// attributes so we could re-parse the ID and metadata when pasted into Warp.
|
||||
let workflow_content = workflow
|
||||
.and_then(|workflow| {
|
||||
if !workflow.is_trashed(cloud_model) {
|
||||
Some(workflow.model().data.content().to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or("".to_owned());
|
||||
|
||||
EmbeddedItemRichFormat {
|
||||
plain_text: workflow_content.clone(),
|
||||
html: EmbeddedItemHTMLRepresentation {
|
||||
element_name: "pre",
|
||||
content: workflow_content,
|
||||
attributes: HashMap::from([(WARP_EMBED_ATTRIBUTE_NAME, self.hashed_id())]),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LaidOutEmbeddedWorkflow {
|
||||
pub title: Arc<TextFrame>,
|
||||
pub description: Option<Arc<TextFrame>>,
|
||||
pub command: Vec<Arc<TextFrame>>,
|
||||
|
||||
pub title_height: Pixels,
|
||||
pub description_height: Option<Pixels>,
|
||||
pub command_height: Pixels,
|
||||
|
||||
width: Pixels,
|
||||
is_agent_mode_prompt: bool,
|
||||
}
|
||||
|
||||
impl LaidOutEmbeddedWorkflow {
|
||||
pub fn new(
|
||||
title: Arc<TextFrame>,
|
||||
description: Option<Arc<TextFrame>>,
|
||||
command: Vec<Arc<TextFrame>>,
|
||||
width: Pixels,
|
||||
is_agent_mode_prompt: bool,
|
||||
) -> Self {
|
||||
let title_height = title
|
||||
.lines()
|
||||
.iter()
|
||||
.fold(0f32, |acc, line| {
|
||||
acc + line.font_size * line.line_height_ratio
|
||||
})
|
||||
.into_pixels();
|
||||
|
||||
let description_height = description.as_ref().map(|description| {
|
||||
description
|
||||
.lines()
|
||||
.iter()
|
||||
.fold(0f32, |acc, line| {
|
||||
acc + line.font_size * line.line_height_ratio
|
||||
})
|
||||
.into_pixels()
|
||||
});
|
||||
|
||||
let command_height = command
|
||||
.iter()
|
||||
.fold(0f32, |acc, frame| {
|
||||
acc + frame.lines().iter().fold(0f32, |acc, line| {
|
||||
acc + line.font_size * line.line_height_ratio
|
||||
})
|
||||
})
|
||||
.into_pixels();
|
||||
|
||||
Self {
|
||||
title,
|
||||
description,
|
||||
command,
|
||||
title_height,
|
||||
description_height,
|
||||
command_height,
|
||||
width,
|
||||
is_agent_mode_prompt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LaidOutEmbeddedItem for LaidOutEmbeddedWorkflow {
|
||||
fn height(&self) -> Pixels {
|
||||
let mut total_height = self.title_height;
|
||||
|
||||
if let Some(height) = self.description_height {
|
||||
total_height += TITLE_TO_DESCRIPTION_PADDING.into_pixels() + height;
|
||||
}
|
||||
|
||||
total_height += DESCRIPTION_TO_COMMAND_PADDING.into_pixels() + self.command_height;
|
||||
total_height
|
||||
}
|
||||
|
||||
fn size(&self) -> Vector2F {
|
||||
vec2f(self.width.as_f32(), self.height().as_f32())
|
||||
}
|
||||
|
||||
fn first_line_bound(&self) -> Vector2F {
|
||||
// Use a constant here so we are consistently aligning the block insertion menu.
|
||||
vec2f(self.width.as_f32(), EMBEDDED_ITEM_FIRST_LINE_HEIGHT)
|
||||
}
|
||||
|
||||
fn element(
|
||||
&self,
|
||||
_state: &RenderState,
|
||||
viewport_item: ViewportItem,
|
||||
model: Option<&dyn EmbeddedItemModel>,
|
||||
ctx: &AppContext,
|
||||
) -> Box<dyn RenderableBlock> {
|
||||
Box::new(RenderableEmbeddedWorkflow::new(
|
||||
viewport_item,
|
||||
model,
|
||||
ctx,
|
||||
self.is_agent_mode_prompt,
|
||||
))
|
||||
}
|
||||
|
||||
fn spacing(&self) -> BlockSpacing {
|
||||
EMBED_WORKFLOW_SPACING
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RenderableEmbeddedWorkflow {
|
||||
viewport_item: ViewportItem,
|
||||
workflow_icon: Box<dyn Element>,
|
||||
border: Option<Border>,
|
||||
footer: Box<dyn Element>,
|
||||
}
|
||||
|
||||
impl RenderableEmbeddedWorkflow {
|
||||
pub fn new(
|
||||
viewport_item: ViewportItem,
|
||||
model: Option<&dyn EmbeddedItemModel>,
|
||||
ctx: &AppContext,
|
||||
is_agent_mode_prompt: bool,
|
||||
) -> Self {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
let (icon, icon_color) = if is_agent_mode_prompt {
|
||||
(
|
||||
Icon::Prompt,
|
||||
warp_drive_icon_color(appearance, DriveObjectType::AgentModeWorkflow),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Icon::Workflow,
|
||||
warp_drive_icon_color(appearance, DriveObjectType::Workflow),
|
||||
)
|
||||
};
|
||||
let workflow_icon = ConstrainedBox::new(
|
||||
icon.to_warpui_icon(icon_color.into())
|
||||
.with_opacity(1.0)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(WORKFLOW_ICON_SIZE)
|
||||
.with_width(WORKFLOW_ICON_SIZE)
|
||||
.finish();
|
||||
|
||||
let footer = match model.and_then(|model| model.render_item_footer(ctx)) {
|
||||
Some(element) => element,
|
||||
None => Empty::new().finish(),
|
||||
};
|
||||
|
||||
Self {
|
||||
viewport_item,
|
||||
workflow_icon,
|
||||
border: model.and_then(|model| model.border(ctx)),
|
||||
footer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderableBlock for RenderableEmbeddedWorkflow {
|
||||
fn viewport_item(&self) -> &ViewportItem {
|
||||
&self.viewport_item
|
||||
}
|
||||
|
||||
fn layout(&mut self, _model: &RenderState, ctx: &mut LayoutContext, app: &AppContext) {
|
||||
self.workflow_icon.layout(
|
||||
SizeConstraint::strict(vec2f(WORKFLOW_ICON_SIZE, WORKFLOW_ICON_SIZE)),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
|
||||
self.footer.layout(
|
||||
SizeConstraint::strict(vec2f(
|
||||
self.viewport_item.content_size.x(),
|
||||
BLOCK_FOOTER_HEIGHT,
|
||||
)),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &AppContext) {
|
||||
let content = model.content();
|
||||
let embedded_workflow = extract_block!(self.viewport_item, content, (block, BlockItem::Embedded(workflow)) => block.embedded(workflow));
|
||||
|
||||
let workflow: &LaidOutEmbeddedWorkflow = embedded_workflow
|
||||
.item
|
||||
.as_any()
|
||||
.downcast_ref()
|
||||
.expect("Should be a workflow");
|
||||
|
||||
// Check if any of the active selections overlap with the embedded workflow.
|
||||
let selected = model.offset_in_active_selection(embedded_workflow.start_char_offset);
|
||||
// Check if any of the cursors are at the start of the embedded workflow.
|
||||
let draw_cursor = model.is_selection_head(embedded_workflow.start_char_offset);
|
||||
|
||||
let styles = model.styles();
|
||||
let base_style = &styles.base_text;
|
||||
let code_style = &styles.embedding_text;
|
||||
|
||||
let border = self.border.unwrap_or(styles.code_border);
|
||||
|
||||
let background_rect = self.viewport_item.visible_bounds(ctx);
|
||||
|
||||
ctx.paint
|
||||
.scene
|
||||
.draw_rect_without_hit_recording(background_rect)
|
||||
.with_border(border)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background(model.styles().embedding_background);
|
||||
|
||||
let mut content_origin = embedded_workflow.content_origin();
|
||||
|
||||
// Vertically center the icon relative to the first line of the title text.
|
||||
let title_line_height = workflow
|
||||
.title
|
||||
.lines()
|
||||
.first()
|
||||
.map_or(workflow.title_height.as_f32(), |line| line.height());
|
||||
let workflow_icon_origin =
|
||||
content_origin + vec2f(0., (title_line_height - WORKFLOW_ICON_SIZE) / 2.);
|
||||
self.workflow_icon
|
||||
.paint(ctx.content_to_screen(workflow_icon_origin), ctx.paint, app);
|
||||
|
||||
content_origin += vec2f(WORKFLOW_TEXT_PADDING, 0.);
|
||||
ctx.draw_text(
|
||||
content_origin,
|
||||
Default::default(),
|
||||
&workflow.title,
|
||||
base_style,
|
||||
);
|
||||
content_origin += vec2f(0., workflow.title_height.as_f32());
|
||||
|
||||
if let Some(description_frame) = &workflow.description {
|
||||
content_origin += vec2f(0., TITLE_TO_DESCRIPTION_PADDING);
|
||||
|
||||
ctx.draw_text(
|
||||
content_origin,
|
||||
Default::default(),
|
||||
description_frame,
|
||||
base_style,
|
||||
);
|
||||
|
||||
content_origin += vec2f(
|
||||
0.,
|
||||
workflow.description_height.expect("Should exist").as_f32(),
|
||||
)
|
||||
}
|
||||
|
||||
content_origin += vec2f(0., DESCRIPTION_TO_COMMAND_PADDING);
|
||||
|
||||
for frame in &workflow.command {
|
||||
ctx.draw_text(content_origin, Default::default(), frame, code_style);
|
||||
|
||||
content_origin += vec2f(
|
||||
0.,
|
||||
frame.lines().iter().fold(0f32, |acc, line| {
|
||||
acc + line.font_size * line.line_height_ratio
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if selected {
|
||||
ctx.paint
|
||||
.scene
|
||||
.draw_rect_with_hit_recording(background_rect)
|
||||
.with_background(styles.selection_fill);
|
||||
}
|
||||
|
||||
if draw_cursor {
|
||||
let line_height = styles.base_text.line_height().as_f32();
|
||||
// The lower right corner of the background rect is at reserved_origin + background_rect.size()
|
||||
// Add some horizontal padding and minus line height vertically so it's visible and aligned to
|
||||
// the bottom of the background rect.
|
||||
let end_of_line_position = embedded_workflow.reserved_origin()
|
||||
+ background_rect.size()
|
||||
+ vec2f(5., -line_height);
|
||||
ctx.draw_and_save_cursor(
|
||||
CursorDisplayType::Bar,
|
||||
end_of_line_position,
|
||||
vec2f(styles.cursor_width, line_height),
|
||||
CursorData::default(),
|
||||
styles,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.paint.scene.start_layer(warpui::ClipBounds::ActiveLayer);
|
||||
|
||||
// Position the block footer right below the content area, flush with its right-hand edge.
|
||||
// This gives the footer some padding relative to the visible area with a background.
|
||||
let content_rect = self.viewport_item.content_bounds(ctx);
|
||||
let button_origin = content_rect.lower_right()
|
||||
- vec2f(
|
||||
self.footer.size().expect("Footer should be laid out").x(),
|
||||
0.,
|
||||
);
|
||||
self.footer.paint(button_origin, ctx.paint, app);
|
||||
|
||||
ctx.paint.scene.stop_layer();
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &warpui::AppContext) {
|
||||
self.footer.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_model: &warp_editor::render::model::RenderState,
|
||||
event: &warpui::event::DispatchedEvent,
|
||||
ctx: &mut warpui::EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.footer.dispatch_event(event, ctx, app)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
use std::{borrow::Cow, mem, ops::Range, sync::Arc};
|
||||
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warp_editor::{
|
||||
content::{anchor::Anchor, buffer::Buffer, selection_model::BufferSelectionModel},
|
||||
editor::EmbeddedItemModel,
|
||||
};
|
||||
use warp_util::user_input::UserInput;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
|
||||
MouseStateHandle, ParentElement, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::{button::ButtonVariant, components::UiComponent},
|
||||
AppContext, Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{model::persistence::CloudModel, CloudObject},
|
||||
completer::SessionAgnosticContext,
|
||||
notebooks::{
|
||||
styles::block_footer_action_button,
|
||||
telemetry::{ActionEntrypoint, BlockInfo},
|
||||
},
|
||||
server::ids::{HashableId, ToServerId},
|
||||
settings::FontSettings,
|
||||
terminal::input::decorations::{parse_current_commands_and_tokens, ParsedTokensSnapshot},
|
||||
themes::theme::AnsiColorIdentifier,
|
||||
ui_components::icons::Icon,
|
||||
util::bindings::CustomAction,
|
||||
workflows::{CloudWorkflow, WorkflowId},
|
||||
};
|
||||
|
||||
use super::{
|
||||
embedded_item::EmbeddedWorkflow,
|
||||
keys::{custom_action_to_display, NotebookKeybindings},
|
||||
model::ChildModelHandle,
|
||||
notebook_command::{parsed_token_to_color_style_ranges, transform_ansi_color_to_solid_color},
|
||||
rich_text_styles,
|
||||
view::EditorViewAction,
|
||||
NotebookWorkflow,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
insert_button_state: MouseStateHandle,
|
||||
copy_button_state: MouseStateHandle,
|
||||
edit_button_state: MouseStateHandle,
|
||||
remove_embedding_button_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct NotebookEmbed {
|
||||
start: Anchor,
|
||||
hashed_id: String,
|
||||
is_selected: bool,
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
cached_syntax_color: Option<Vec<(Range<ByteOffset>, AnsiColorIdentifier)>>,
|
||||
}
|
||||
|
||||
impl NotebookEmbed {
|
||||
pub fn new(
|
||||
start: CharOffset,
|
||||
hashed_id: String,
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let start = selection_model.update(ctx, |selection_model, ctx| {
|
||||
selection_model.anchor(start, ctx)
|
||||
});
|
||||
|
||||
let embedding = Self {
|
||||
start,
|
||||
hashed_id,
|
||||
content,
|
||||
selection_model,
|
||||
is_selected: false,
|
||||
mouse_state_handles: Default::default(),
|
||||
cached_syntax_color: None,
|
||||
};
|
||||
|
||||
embedding.highlight_syntax(ctx);
|
||||
embedding
|
||||
}
|
||||
|
||||
pub fn highlight_syntax(&self, ctx: &mut ModelContext<Self>) {
|
||||
let completion_context = SessionAgnosticContext::new(CommandRegistry::global_instance());
|
||||
if let Some(command) = self
|
||||
.maybe_get_workflow(ctx)
|
||||
.and_then(|workflow| workflow.model().data.command())
|
||||
{
|
||||
let command = command.to_string();
|
||||
let _ = ctx.spawn(
|
||||
async move { parse_current_commands_and_tokens(command, &completion_context).await },
|
||||
|notebook_embed, parsed_tokens, ctx| {
|
||||
notebook_embed.update_buffer_with_parsed_tokens(parsed_tokens, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_buffer_with_parsed_tokens(
|
||||
&mut self,
|
||||
parsed_tokens: ParsedTokensSnapshot,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let colors = parsed_token_to_color_style_ranges(parsed_tokens.parsed_tokens);
|
||||
self.cached_syntax_color = Some(colors.clone());
|
||||
|
||||
self.update_buffer_with_syntax_color(&colors, ctx);
|
||||
}
|
||||
|
||||
pub fn try_apply_cached_highlighting(&self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(colors) = &self.cached_syntax_color {
|
||||
self.update_buffer_with_syntax_color(colors, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_buffer_with_syntax_color(
|
||||
&self,
|
||||
colors: &[(Range<ByteOffset>, AnsiColorIdentifier)],
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(offset) = self.start_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let font_settings = FontSettings::as_ref(ctx);
|
||||
let terminal_colors_normal = appearance.theme().terminal_colors().normal.to_owned();
|
||||
let background_color = rich_text_styles(appearance, font_settings)
|
||||
.embedding_background
|
||||
.start_color();
|
||||
|
||||
self.content.update(ctx, |buffer, ctx| {
|
||||
buffer.replace_embedding_at_offset(
|
||||
offset,
|
||||
Arc::new(
|
||||
EmbeddedWorkflow::new(self.hashed_id.clone()).with_syntax_highlighting(
|
||||
transform_ansi_color_to_solid_color(
|
||||
colors,
|
||||
&terminal_colors_normal,
|
||||
background_color,
|
||||
),
|
||||
),
|
||||
),
|
||||
self.selection_model.clone(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn hashed_id(&self) -> &str {
|
||||
self.hashed_id.as_str()
|
||||
}
|
||||
|
||||
pub fn refresh_item_state(&self, ctx: &mut ModelContext<Self>) {
|
||||
let Some(offset) = self.start_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.content.update(ctx, |buffer, ctx| {
|
||||
buffer.replace_embedding_at_offset(
|
||||
offset,
|
||||
Arc::new(EmbeddedWorkflow::new(self.hashed_id.clone())),
|
||||
self.selection_model.clone(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Re-highlight syntax since the command might have changed.
|
||||
self.highlight_syntax(ctx);
|
||||
}
|
||||
|
||||
fn maybe_get_workflow<'a>(&self, ctx: &'a AppContext) -> Option<&'a CloudWorkflow> {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
|
||||
// Currently we are only supporting embedded workflows. We could support
|
||||
// more drive objects in the future.
|
||||
let id = WorkflowId::from_hash(&self.hashed_id)?;
|
||||
cloud_model
|
||||
.get_by_uid(&id.to_server_id().uid())
|
||||
.and_then(|object| object.as_any().downcast_ref::<CloudWorkflow>())
|
||||
.and_then(|workflow| {
|
||||
if workflow.is_trashed(cloud_model) {
|
||||
None
|
||||
} else {
|
||||
Some(workflow)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start_offset(&self, ctx: &impl ModelAsRef) -> Option<CharOffset> {
|
||||
self.selection_model.as_ref(ctx).resolve_anchor(&self.start)
|
||||
}
|
||||
|
||||
fn selectable(&self, ctx: &AppContext) -> bool {
|
||||
self.maybe_get_workflow(ctx).is_some()
|
||||
}
|
||||
|
||||
fn render_footer_for_workflow(
|
||||
&self,
|
||||
workflow: &CloudWorkflow,
|
||||
appearance: &Appearance,
|
||||
ctx: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut footer = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End);
|
||||
|
||||
let workflow_id = workflow.id;
|
||||
let workflow_info = NotebookWorkflow::from_cloud_workflow(Box::new(workflow.clone()));
|
||||
let block_info = BlockInfo::EmbeddedWorkflow {
|
||||
workflow_id: workflow_id.into_server().map(Into::into),
|
||||
team_uid: workflow.permissions.owner.into(),
|
||||
};
|
||||
|
||||
let workflow_content = workflow.model().data.content().to_owned();
|
||||
footer.add_child(Shrinkable::new(1.0, Empty::new().finish()).finish());
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::Pencil,
|
||||
self.mouse_state_handles.edit_button_state.clone(),
|
||||
"Edit",
|
||||
None,
|
||||
)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::EditWorkflow(workflow_id));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::Copy,
|
||||
self.mouse_state_handles.copy_button_state.clone(),
|
||||
"Copy",
|
||||
custom_action_to_display(CustomAction::Copy),
|
||||
)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::CopyTextToClipboard {
|
||||
text: UserInput::new(workflow_content.clone()),
|
||||
block: block_info,
|
||||
entrypoint: ActionEntrypoint::Button,
|
||||
});
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::TerminalInput,
|
||||
self.mouse_state_handles.insert_button_state.clone(),
|
||||
"Run in terminal",
|
||||
NotebookKeybindings::as_ref(ctx).run_commands_keybinding(),
|
||||
)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::RunWorkflow(workflow_info.clone()));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
footer.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NotebookEmbed {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl EmbeddedItemModel for NotebookEmbed {
|
||||
fn render_item_footer(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
// Currently we are only supporting embedded workflows. We could support
|
||||
// more drive objects in the future.
|
||||
let workflow = self.maybe_get_workflow(ctx);
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
workflow.map(|workflow| self.render_footer_for_workflow(workflow, appearance, ctx))
|
||||
}
|
||||
|
||||
fn border(&self, app: &AppContext) -> Option<Border> {
|
||||
if self.is_selected {
|
||||
let border_fill = Appearance::as_ref(app).theme().accent();
|
||||
Some(Border::all(3.).with_border_fill(border_fill))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn render_remove_embedding_button(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let offset = self.start_offset(ctx)?;
|
||||
Some(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Text,
|
||||
self.mouse_state_handles
|
||||
.remove_embedding_button_state
|
||||
.clone(),
|
||||
)
|
||||
.with_text_label("Remove".to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::Arrow)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::RemoveEmbeddingAt(offset));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(12.)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChildModelHandle for ModelHandle<NotebookEmbed> {
|
||||
fn start_offset(&self, app: &AppContext) -> Option<CharOffset> {
|
||||
self.as_ref(app).start_offset(app)
|
||||
}
|
||||
|
||||
fn end_offset(&self, app: &AppContext) -> Option<CharOffset> {
|
||||
// Embedding should always take one character offset.
|
||||
self.as_ref(app).start_offset(app).map(|offset| offset + 1)
|
||||
}
|
||||
|
||||
fn selectable(&self, app: &AppContext) -> bool {
|
||||
self.as_ref(app).selectable(app)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn executable_workflow(&self, app: &AppContext) -> Option<NotebookWorkflow> {
|
||||
// Currently we are only supporting embedded workflows. We could support
|
||||
// more drive objects in the future.
|
||||
self.as_ref(app)
|
||||
.maybe_get_workflow(app)
|
||||
.map(|workflow| NotebookWorkflow::from_cloud_workflow(Box::new(workflow.clone())))
|
||||
}
|
||||
|
||||
fn executable_command<'a>(&'a self, app: &'a AppContext) -> Option<Cow<'a, str>> {
|
||||
self.as_ref(app)
|
||||
.maybe_get_workflow(app)
|
||||
.map(|workflow| workflow.model().data.content().into())
|
||||
}
|
||||
|
||||
fn selected(&self, app: &AppContext) -> bool {
|
||||
self.as_ref(app).is_selected
|
||||
}
|
||||
|
||||
fn set_selected(&self, selected: bool, ctx: &mut AppContext) -> bool {
|
||||
self.update(ctx, |model, _ctx| {
|
||||
mem::replace(&mut model.is_selected, selected)
|
||||
})
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn ChildModelHandle> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
use std::{fmt::Write, time::Duration};
|
||||
|
||||
use async_channel::Sender;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_editor::{
|
||||
render::model::{AutoScrollMode, Decoration},
|
||||
search::{SearchEvent, Searcher},
|
||||
};
|
||||
use warpui::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole},
|
||||
elements::{
|
||||
Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Empty, Flex, MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
|
||||
PositionedElementOffsetBounds, Radius, Rect, Shrinkable, Stack,
|
||||
},
|
||||
platform::Cursor,
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
toggle_button::ToggleButton,
|
||||
},
|
||||
AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity,
|
||||
TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
debounce::debounce,
|
||||
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
|
||||
ui_components::icons::Icon,
|
||||
view_components::find::{
|
||||
CASE_SENSITIVE_LABEL, CASE_SENSITIVE_TOOLTIP, FIND_BAR_WIDTH, REGEX_TOGGLE_LABEL,
|
||||
REGEX_TOGGLE_TOOLTIP,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
model::NotebooksEditorModel,
|
||||
view::{EditorViewEvent, RichTextEditorView},
|
||||
};
|
||||
|
||||
/// View for the find bar within a notebook.
|
||||
pub struct FindBar {
|
||||
searcher: ModelHandle<Searcher>,
|
||||
editor_model: ModelHandle<NotebooksEditorModel>,
|
||||
query_editor: ViewHandle<EditorView>,
|
||||
query_change_tx: Sender<()>,
|
||||
button_handles: ButtonHandles,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ButtonHandles {
|
||||
regex_toggle: MouseStateHandle,
|
||||
case_sensitive_toggle: MouseStateHandle,
|
||||
next_match: MouseStateHandle,
|
||||
previous_match: MouseStateHandle,
|
||||
close: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FindBarEvent {
|
||||
Close,
|
||||
SearchDecorationsChanged,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FindBarAction {
|
||||
ToggleRegex,
|
||||
ToggleCaseSensitive,
|
||||
FocusNextMatch,
|
||||
FocusPreviousMatch,
|
||||
Close,
|
||||
}
|
||||
|
||||
const QUERY_DEBOUNCE_PERIOD: Duration = Duration::from_millis(20);
|
||||
|
||||
impl FindBar {
|
||||
pub fn new(
|
||||
editor_model: ModelHandle<NotebooksEditorModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let searcher = editor_model.update(ctx, |model, ctx| model.new_search(ctx));
|
||||
let query_editor = ctx.add_typed_action_view(|ctx| {
|
||||
EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
// Ensure the search input font size is consistent with the button labels.
|
||||
text: TextOptions::ui_font_size(Appearance::as_ref(ctx)),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
ctx.subscribe_to_view(&query_editor, Self::handle_query_editor_event);
|
||||
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
ctx.spawn_stream_local(
|
||||
debounce(QUERY_DEBOUNCE_PERIOD, rx),
|
||||
Self::handle_debounced_query_change,
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&searcher, Self::handle_search_event);
|
||||
|
||||
Self {
|
||||
searcher,
|
||||
editor_model,
|
||||
query_editor,
|
||||
query_change_tx: tx,
|
||||
button_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the query editor is focused.
|
||||
pub fn query_editor_focused(&self, app: &AppContext) -> bool {
|
||||
self.query_editor.is_focused(app)
|
||||
}
|
||||
|
||||
/// Decorations for the current find-bar search results.
|
||||
pub fn decorations(&self, ctx: &AppContext) -> Vec<Decoration> {
|
||||
self.searcher.as_ref(ctx).result_decorations()
|
||||
}
|
||||
|
||||
fn handle_query_editor_event(
|
||||
&mut self,
|
||||
_editor: ViewHandle<EditorView>,
|
||||
event: &EditorEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
EditorEvent::Edited(_) => {
|
||||
let _ = self.query_change_tx.try_send(());
|
||||
}
|
||||
EditorEvent::Enter => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.select_next_result(ctx));
|
||||
}
|
||||
EditorEvent::ShiftEnter | EditorEvent::AltEnter => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.select_previous_result(ctx));
|
||||
}
|
||||
EditorEvent::Escape => ctx.emit(FindBarEvent::Close),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_debounced_query_change(&mut self, _event: (), ctx: &mut ViewContext<Self>) {
|
||||
let query = self.query_editor.as_ref(ctx).buffer_text(ctx);
|
||||
self.searcher
|
||||
.update(ctx, |searcher, ctx| searcher.set_query(query, ctx));
|
||||
}
|
||||
|
||||
fn handle_search_event(
|
||||
&mut self,
|
||||
_model: ModelHandle<Searcher>,
|
||||
event: &SearchEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
SearchEvent::Updated => {
|
||||
// We ask the parent view to update decorations instead of doing it ourselves. This
|
||||
// way, it can merge together decorations from multiple sources.
|
||||
ctx.emit(FindBarEvent::SearchDecorationsChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
SearchEvent::SelectedResultChanged => {
|
||||
if let Some(autoscroll_match) = self.searcher.as_ref(ctx).selected_match_range() {
|
||||
self.editor_model.as_ref(ctx).render_state().clone().update(
|
||||
ctx,
|
||||
|render_state, _ctx| {
|
||||
render_state.request_autoscroll_to(
|
||||
AutoScrollMode::ScrollOffsetsIntoViewport(autoscroll_match),
|
||||
);
|
||||
},
|
||||
)
|
||||
}
|
||||
ctx.emit(FindBarEvent::SearchDecorationsChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
SearchEvent::InvalidQuery => {
|
||||
// TODO: Show an error border?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line height for the query editor.
|
||||
fn editor_height(&self, appearance: &Appearance, app: &AppContext) -> f32 {
|
||||
self.query_editor
|
||||
.as_ref(app)
|
||||
.line_height(app.font_cache(), appearance)
|
||||
}
|
||||
|
||||
fn render_match_index(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let searcher = self.searcher.as_ref(app);
|
||||
if searcher.has_query() {
|
||||
let match_count = searcher.match_count();
|
||||
let text = if match_count == 0 {
|
||||
"No matches".to_string()
|
||||
} else {
|
||||
let mut text = String::new();
|
||||
match searcher.selected_match() {
|
||||
Some(idx) => {
|
||||
let _ = write!(&mut text, "{}", idx + 1);
|
||||
}
|
||||
None => text.push('?'),
|
||||
}
|
||||
text.push('/');
|
||||
let _ = write!(&mut text, "{match_count}");
|
||||
text
|
||||
};
|
||||
|
||||
appearance.ui_builder().span(text).build().finish()
|
||||
} else {
|
||||
Empty::new().finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the separator between the query and search options.
|
||||
fn render_separator_line(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_background(
|
||||
appearance
|
||||
.theme()
|
||||
.foreground_button_color()
|
||||
.with_opacity(20),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(1.)
|
||||
.with_height(self.editor_height(appearance, app) + 16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_top(7.)
|
||||
.with_padding_bottom(7.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_action_button(
|
||||
&self,
|
||||
icon: Icon,
|
||||
action: FindBarAction,
|
||||
enabled: bool,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let size = self.editor_height(appearance, app);
|
||||
|
||||
let base_styles = self
|
||||
.button_styles(appearance, app)
|
||||
// We have to add back in space for the padding, because Button applies its size
|
||||
// constraint around the padding and border.
|
||||
.set_width(size + 16.)
|
||||
.set_height(size + 16.);
|
||||
let mut button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, mouse_state_handle)
|
||||
// The fill here doesn't matter, since it's overridden by the button text color.
|
||||
.with_icon_label(icon.to_warpui_icon(crate::themes::theme::Fill::white()))
|
||||
.with_style(base_styles)
|
||||
.with_hovered_styles(UiComponentStyles {
|
||||
background: Some(appearance.theme().foreground_button_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_disabled_styles(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().nonactive_ui_text_color().into()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if !enabled {
|
||||
button = button.disabled();
|
||||
}
|
||||
|
||||
let button = button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(action);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
Container::new(button)
|
||||
.with_vertical_padding(8.)
|
||||
.with_padding_left(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Render a toggle button for one of the search options.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_toggle_button(
|
||||
&self,
|
||||
text: &str,
|
||||
tooltip: &str,
|
||||
action: FindBarAction,
|
||||
toggled_on: bool,
|
||||
mouse_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let button = ToggleButton::new(mouse_state, self.button_styles(appearance, app))
|
||||
.with_label(text)
|
||||
.with_toggled_on(toggled_on)
|
||||
.with_hovered_styles(UiComponentStyles {
|
||||
background: Some(appearance.theme().foreground_button_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_toggled_on_styles(UiComponentStyles {
|
||||
background: Some(appearance.theme().find_bar_button_selection_color().into()),
|
||||
border_color: Some(appearance.theme().accent().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_tooltip(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.tool_tip(tooltip.to_string())
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action))
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
Container::new(button)
|
||||
.with_vertical_padding(8.)
|
||||
.with_padding_left(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Shared styles for find-bar buttons.
|
||||
fn button_styles(&self, appearance: &Appearance, app: &AppContext) -> UiComponentStyles {
|
||||
let size = self.editor_height(appearance, app);
|
||||
UiComponentStyles {
|
||||
width: Some(size),
|
||||
height: Some(size),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
|
||||
border_width: Some(1.),
|
||||
padding: Some(Coords::uniform(7.)),
|
||||
font_size: Some(appearance.ui_font_size()),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(appearance.theme().active_ui_text_color().into_solid()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FindBar {
|
||||
type Event = FindBarEvent;
|
||||
}
|
||||
|
||||
impl View for FindBar {
|
||||
fn ui_name() -> &'static str {
|
||||
"FindBar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let searcher = self.searcher.as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let editor_height = self.editor_height(appearance, app);
|
||||
let has_matches = searcher.match_count() > 0;
|
||||
|
||||
let find_icon = Container::new(
|
||||
ConstrainedBox::new(Icon::Find.to_warpui_icon(theme.active_ui_detail()).finish())
|
||||
.with_height(editor_height)
|
||||
.with_width(editor_height)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_top(16.)
|
||||
.with_padding_bottom(16.)
|
||||
.finish();
|
||||
|
||||
let find_editor = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Clipped::new(ChildView::new(&self.query_editor).finish()).finish(),
|
||||
)
|
||||
.finish(),
|
||||
self.render_match_index(appearance, app),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.with_height(editor_height)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(8.)
|
||||
.with_vertical_padding(16.)
|
||||
.finish();
|
||||
|
||||
let find_box = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([
|
||||
find_icon,
|
||||
Shrinkable::new(1., find_editor).finish(),
|
||||
self.render_separator_line(appearance, app),
|
||||
self.render_action_button(
|
||||
Icon::ChevronUp,
|
||||
FindBarAction::FocusPreviousMatch,
|
||||
has_matches,
|
||||
self.button_handles.previous_match.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
self.render_action_button(
|
||||
Icon::ChevronDown,
|
||||
FindBarAction::FocusNextMatch,
|
||||
has_matches,
|
||||
self.button_handles.next_match.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
self.render_toggle_button(
|
||||
REGEX_TOGGLE_LABEL,
|
||||
REGEX_TOGGLE_TOOLTIP,
|
||||
FindBarAction::ToggleRegex,
|
||||
searcher.is_regex(),
|
||||
self.button_handles.regex_toggle.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
self.render_toggle_button(
|
||||
CASE_SENSITIVE_LABEL,
|
||||
CASE_SENSITIVE_TOOLTIP,
|
||||
FindBarAction::ToggleCaseSensitive,
|
||||
searcher.is_case_sensitive(),
|
||||
self.button_handles.case_sensitive_toggle.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
self.render_action_button(
|
||||
Icon::X,
|
||||
FindBarAction::Close,
|
||||
true,
|
||||
self.button_handles.close.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
]);
|
||||
|
||||
let container = Container::new(
|
||||
ConstrainedBox::new(find_box.finish())
|
||||
.with_width(FIND_BAR_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(14.)
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.finish();
|
||||
|
||||
Container::new(container)
|
||||
.with_padding_top(10.)
|
||||
.with_padding_right(20.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
// Enable auto-selection so that new search results automatically select the
|
||||
// nearest match from the cursor, avoiding a "?" in the result counter.
|
||||
self.searcher.update(ctx, |searcher, _ctx| {
|
||||
searcher.set_auto_select(true);
|
||||
});
|
||||
|
||||
if focus_ctx.is_self_focused() {
|
||||
self.query_editor
|
||||
.update(ctx, |editor, ctx| editor.select_all(ctx));
|
||||
ctx.focus(&self.query_editor);
|
||||
|
||||
// If reopening with cached results but no selection, select the nearest match.
|
||||
let should_select = {
|
||||
let searcher = self.searcher.as_ref(ctx);
|
||||
searcher.match_count() > 0 && searcher.selected_match().is_none()
|
||||
};
|
||||
if should_select {
|
||||
self.searcher
|
||||
.update(ctx, |searcher, ctx| searcher.select_next_from_cursor(ctx));
|
||||
}
|
||||
|
||||
// If there's a cached previous search, show the results.
|
||||
ctx.emit(FindBarEvent::SearchDecorationsChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, _blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
// Check if focus moved to the query editor (a child of this view).
|
||||
let focused_view_id = ctx.focused_view_id(ctx.window_id());
|
||||
let is_focus_within = focused_view_id == Some(self.query_editor.id());
|
||||
|
||||
if !is_focus_within {
|
||||
self.searcher.update(ctx, |searcher, ctx| {
|
||||
searcher.clear_selected_result(ctx);
|
||||
searcher.set_auto_select(false);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for FindBar {
|
||||
type Action = FindBarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
FindBarAction::ToggleRegex => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.set_regex(!search.is_regex(), ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
FindBarAction::ToggleCaseSensitive => {
|
||||
self.searcher.update(ctx, |search, ctx| {
|
||||
search.set_case_sensitive(!search.is_case_sensitive(), ctx)
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
FindBarAction::FocusNextMatch => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.select_next_result(ctx));
|
||||
}
|
||||
FindBarAction::FocusPreviousMatch => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.select_previous_result(ctx));
|
||||
}
|
||||
FindBarAction::Close => {
|
||||
ctx.emit(FindBarEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_accessibility_contents(
|
||||
&mut self,
|
||||
action: &Self::Action,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ActionAccessibilityContent {
|
||||
let text = match action {
|
||||
FindBarAction::ToggleRegex => {
|
||||
if self.searcher.as_ref(ctx).is_regex() {
|
||||
"Enable regex search"
|
||||
} else {
|
||||
"Disable regex search"
|
||||
}
|
||||
}
|
||||
FindBarAction::ToggleCaseSensitive => {
|
||||
if self.searcher.as_ref(ctx).is_case_sensitive() {
|
||||
"Enable case-sensitive search"
|
||||
} else {
|
||||
"Disable case-sensitive search"
|
||||
}
|
||||
}
|
||||
FindBarAction::FocusNextMatch => "Focus next match",
|
||||
FindBarAction::FocusPreviousMatch => "Focus previous match",
|
||||
FindBarAction::Close => "Close find bar",
|
||||
};
|
||||
Some(AccessibilityContent::new_without_help(
|
||||
text,
|
||||
WarpA11yRole::UserAction,
|
||||
))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// State for embedding a find bar in a rich-text editor.
|
||||
pub struct FindBarState {
|
||||
bar_view: ViewHandle<FindBar>,
|
||||
is_open: bool,
|
||||
parent_position: String,
|
||||
}
|
||||
|
||||
impl FindBarState {
|
||||
pub fn new(
|
||||
parent_position: String,
|
||||
model: ModelHandle<NotebooksEditorModel>,
|
||||
ctx: &mut ViewContext<RichTextEditorView>,
|
||||
) -> Self {
|
||||
let bar_view = ctx.add_typed_action_view(|ctx| FindBar::new(model, ctx));
|
||||
Self {
|
||||
parent_position,
|
||||
bar_view,
|
||||
is_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(&self) -> &ViewHandle<FindBar> {
|
||||
&self.bar_view
|
||||
}
|
||||
|
||||
/// Whether or not the find bar is focused.
|
||||
pub fn is_focused(&self, app: &AppContext) -> bool {
|
||||
self.bar_view.is_focused(app) || self.bar_view.as_ref(app).query_editor_focused(app)
|
||||
}
|
||||
|
||||
/// Decorations to highlight find-bar matches.
|
||||
pub fn decorations(&self, app: &AppContext) -> Vec<Decoration> {
|
||||
if self.is_open {
|
||||
self.bar_view.as_ref(app).decorations(app)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the find bar, if open.
|
||||
pub fn render(&self, stack: &mut Stack) {
|
||||
if self.is_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&self.bar_view).finish(),
|
||||
OffsetPositioning::offset_from_save_position_element(
|
||||
self.parent_position.clone(),
|
||||
vec2f(-4., -4.),
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
PositionedElementAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open and focus the find bar.
|
||||
pub fn show(&mut self, ctx: &mut ViewContext<RichTextEditorView>) {
|
||||
self.is_open = true;
|
||||
ctx.focus(&self.bar_view);
|
||||
ctx.emit(EditorViewEvent::OpenedFindBar);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Hide the find bar. If search matches were highlighted, the parent view should clear them.
|
||||
pub fn hide(&mut self, ctx: &mut ViewContext<RichTextEditorView>) {
|
||||
self.is_open = false;
|
||||
ctx.focus_self();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use crate::editor::InteractionState;
|
||||
|
||||
pub struct InteractionStateModel {
|
||||
state: InteractionState,
|
||||
is_block_selected: bool,
|
||||
}
|
||||
|
||||
impl InteractionStateModel {
|
||||
pub fn new(initial_state: InteractionState) -> Self {
|
||||
Self {
|
||||
state: initial_state,
|
||||
is_block_selected: false, // refers to whether any block in the given notebook is selected
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_interaction_state(
|
||||
&mut self,
|
||||
new_state: InteractionState,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.state = new_state;
|
||||
ctx.emit(InteractionStateModelEvent::InteractionStateChanged { new_state });
|
||||
}
|
||||
|
||||
pub fn interaction_state(&self) -> InteractionState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn is_block_selected(&self) -> bool {
|
||||
self.is_block_selected
|
||||
}
|
||||
|
||||
pub fn set_is_block_selected(&mut self, is_selected: bool, ctx: &mut ModelContext<Self>) {
|
||||
self.is_block_selected = is_selected;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InteractionStateModelEvent {
|
||||
InteractionStateChanged { new_state: InteractionState },
|
||||
}
|
||||
|
||||
impl Entity for InteractionStateModel {
|
||||
type Event = InteractionStateModelEvent;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Utilities for notebook keybindings.
|
||||
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier},
|
||||
util::bindings::{custom_tag_to_keystroke, keybinding_name_to_display_string, CustomAction},
|
||||
};
|
||||
|
||||
pub const RUN_COMMANDS_KEYBINDING_NAME: &str = "editor_view:run_commands";
|
||||
|
||||
/// Cache of keybindings used in notebooks.
|
||||
pub struct NotebookKeybindings {
|
||||
// Cache of editable keybinding names, to render in tooltips. This cache is necessary because
|
||||
// looking up a keybinding requires a [`AppContext`], so it can't be done when
|
||||
// rendering.
|
||||
//
|
||||
// Inspired by https://github.com/warpdotdev/warp-internal/pull/5676 (see the `Workspace` view)
|
||||
run_commands_keybinding: Option<String>,
|
||||
}
|
||||
|
||||
impl NotebookKeybindings {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(
|
||||
&KeybindingChangedNotifier::handle(ctx),
|
||||
Self::handle_keybinding_change,
|
||||
);
|
||||
Self {
|
||||
run_commands_keybinding: keybinding_name_to_display_string(
|
||||
RUN_COMMANDS_KEYBINDING_NAME,
|
||||
ctx,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display label for the keybinding to run commands in a notebook.
|
||||
pub fn run_commands_keybinding(&self) -> Option<String> {
|
||||
self.run_commands_keybinding.clone()
|
||||
}
|
||||
|
||||
fn handle_keybinding_change(
|
||||
&mut self,
|
||||
event: &KeybindingChangedEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let KeybindingChangedEvent::BindingChanged {
|
||||
binding_name,
|
||||
new_trigger,
|
||||
} = event;
|
||||
if binding_name == RUN_COMMANDS_KEYBINDING_NAME {
|
||||
self.run_commands_keybinding = new_trigger.as_ref().map(|key| key.displayed());
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NotebookKeybindings {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for NotebookKeybindings {}
|
||||
|
||||
/// The keybinding label to display for a [`CustomAction`].
|
||||
pub fn custom_action_to_display(action: CustomAction) -> Option<String> {
|
||||
custom_tag_to_keystroke(action.into()).map(|keystroke| keystroke.displayed())
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use warp_editor::{editor::NavigationKey, model::RichTextEditorModel, render::model::RenderState};
|
||||
use warpui::{
|
||||
elements::{
|
||||
AnchorPair, Container, Flex, MouseStateHandle, OffsetPositioning, OffsetType,
|
||||
ParentElement, PositionedElementOffsetBounds, PositioningAxis, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
fonts::Weight,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, BlurContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
|
||||
TextOptions,
|
||||
},
|
||||
};
|
||||
|
||||
use super::model::NotebooksEditorModel;
|
||||
|
||||
const EDITOR_WIDTH: f32 = 368.;
|
||||
const EDITOR_VERTICAL_PADDING: f32 = 12.;
|
||||
const EDITOR_MARGIN: f32 = 16.;
|
||||
const BETWEEN_EDITOR_MARGIN: f32 = 8.;
|
||||
|
||||
pub enum LinkEditorEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LinkEditorAction {
|
||||
ApplyLink,
|
||||
}
|
||||
|
||||
pub struct LinkEditor {
|
||||
model: ModelHandle<NotebooksEditorModel>,
|
||||
tag_editor: ViewHandle<EditorView>,
|
||||
url_editor: ViewHandle<EditorView>,
|
||||
apply_link_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl LinkEditor {
|
||||
pub fn new(model: ModelHandle<NotebooksEditorModel>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let editor_options = SingleLineEditorOptions {
|
||||
text: TextOptions::ui_text(None, appearance),
|
||||
propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tag_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let mut editor = EditorView::single_line(editor_options.clone(), ctx);
|
||||
editor.set_placeholder_text("Text", ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&tag_editor, |notebook, _, event, ctx| {
|
||||
notebook.handle_tag_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
let url_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let mut editor = EditorView::single_line(editor_options.clone(), ctx);
|
||||
editor.set_placeholder_text("Link (web or file)", ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&url_editor, |notebook, _, event, ctx| {
|
||||
notebook.handle_url_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
LinkEditor {
|
||||
model,
|
||||
tag_editor,
|
||||
url_editor,
|
||||
apply_link_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editors_focused(&self, app: &AppContext) -> bool {
|
||||
self.tag_editor.is_focused(app) || self.url_editor.is_focused(app)
|
||||
}
|
||||
|
||||
/// Focus the URL editor.
|
||||
pub fn focus_url_editor(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus(&self.url_editor);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn url_editor(&self) -> &ViewHandle<EditorView> {
|
||||
&self.url_editor
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn tag_editor(&self) -> &ViewHandle<EditorView> {
|
||||
&self.tag_editor
|
||||
}
|
||||
|
||||
/// Populate the link editor with the state of the active selection.
|
||||
pub fn populate(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let buffer_model = self.model.as_ref(ctx);
|
||||
let selected_content = buffer_model.selected_text(ctx);
|
||||
let url_at_selection = buffer_model.link_at_selection_head(ctx);
|
||||
self.tag_editor.update(ctx, |view, ctx| {
|
||||
view.clear_buffer_and_reset_undo_stack(ctx);
|
||||
view.set_buffer_text(&selected_content, ctx);
|
||||
});
|
||||
|
||||
self.url_editor.update(ctx, |view, ctx| {
|
||||
view.clear_buffer_and_reset_undo_stack(ctx);
|
||||
|
||||
if let Some(url) = &url_at_selection {
|
||||
view.set_buffer_text(url, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_tag_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Edited(_) => ctx.notify(),
|
||||
EditorEvent::Enter
|
||||
| EditorEvent::Navigate(NavigationKey::Tab | NavigationKey::ShiftTab) => {
|
||||
ctx.focus(&self.url_editor)
|
||||
}
|
||||
EditorEvent::Escape => ctx.emit(LinkEditorEvent::Close),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_url_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Edited(_) => ctx.notify(),
|
||||
EditorEvent::Enter => self.apply_link(ctx),
|
||||
EditorEvent::Navigate(NavigationKey::Tab | NavigationKey::ShiftTab) => {
|
||||
ctx.focus(&self.tag_editor)
|
||||
}
|
||||
EditorEvent::Escape => ctx.emit(LinkEditorEvent::Close),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the link editor is in a valid state that can be applied.
|
||||
fn is_valid(&self, ctx: &AppContext) -> bool {
|
||||
!self.tag_editor.as_ref(ctx).is_empty(ctx) && !self.url_editor.as_ref(ctx).is_empty(ctx)
|
||||
}
|
||||
|
||||
/// Apply the current link tag and url to the selected text and close the link editor.
|
||||
fn apply_link(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !self.is_valid(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tag = self.tag_editor.as_ref(ctx).buffer_text(ctx);
|
||||
let url = self.url_editor.as_ref(ctx).buffer_text(ctx);
|
||||
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.set_link(tag, url, ctx);
|
||||
});
|
||||
|
||||
ctx.emit(LinkEditorEvent::Close);
|
||||
}
|
||||
|
||||
pub fn positioning(render_state: &RenderState) -> OffsetPositioning {
|
||||
let selection_position = render_state.saved_positions().text_selection_id();
|
||||
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&selection_position,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Middle, XAxisAnchor::Middle),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&selection_position,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(4.),
|
||||
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for LinkEditor {
|
||||
type Event = LinkEditorEvent;
|
||||
}
|
||||
|
||||
impl View for LinkEditor {
|
||||
fn ui_name() -> &'static str {
|
||||
"LinkEditor"
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
if blur_ctx.is_self_blurred() {
|
||||
ctx.emit(LinkEditorEvent::Close);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut editors = Flex::column();
|
||||
|
||||
editors.add_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(self.tag_editor.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(EDITOR_WIDTH),
|
||||
padding: Some(Coords {
|
||||
top: EDITOR_VERTICAL_PADDING,
|
||||
bottom: EDITOR_VERTICAL_PADDING,
|
||||
left: EDITOR_MARGIN,
|
||||
right: EDITOR_MARGIN,
|
||||
}),
|
||||
margin: Some(Coords {
|
||||
top: EDITOR_MARGIN,
|
||||
bottom: BETWEEN_EDITOR_MARGIN,
|
||||
left: EDITOR_MARGIN,
|
||||
right: EDITOR_MARGIN,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
);
|
||||
editors.add_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(self.url_editor.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(EDITOR_WIDTH),
|
||||
padding: Some(Coords {
|
||||
top: EDITOR_VERTICAL_PADDING,
|
||||
bottom: EDITOR_VERTICAL_PADDING,
|
||||
left: EDITOR_MARGIN,
|
||||
right: EDITOR_MARGIN,
|
||||
}),
|
||||
margin: Some(Coords {
|
||||
left: EDITOR_MARGIN,
|
||||
right: EDITOR_MARGIN,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut link_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.apply_link_mouse_state.clone())
|
||||
.with_centered_text_label("Apply link".to_string());
|
||||
|
||||
// Disable the link button if either of the editors are empty.
|
||||
if !self.is_valid(app) {
|
||||
link_button = link_button.disabled();
|
||||
};
|
||||
|
||||
editors.add_child(
|
||||
link_button
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(EDITOR_WIDTH),
|
||||
margin: Some(Coords::uniform(EDITOR_MARGIN)),
|
||||
font_weight: Some(Weight::Bold),
|
||||
padding: Some(Coords {
|
||||
left: EDITOR_VERTICAL_PADDING,
|
||||
right: EDITOR_VERTICAL_PADDING,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(LinkEditorAction::ApplyLink))
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Container::new(editors.finish())
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for LinkEditor {
|
||||
type Action = LinkEditorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
if matches!(action, LinkEditorAction::ApplyLink) {
|
||||
self.apply_link(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Rich-text notebooks editor.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::{builder::CHECK_SVG_PATH, theme::color::internal_colors};
|
||||
use warp_editor::{
|
||||
content::text::{
|
||||
BlockHeaderSize, BlockType as ContentBlockType, BufferBlockStyle, CodeBlockType,
|
||||
},
|
||||
render::model::{
|
||||
BrokenLinkStyle, CheckBoxStyle, EmbeddedItem, HorizontalRuleStyle, InlineCodeStyle,
|
||||
ParagraphStyles, RichTextStyles, TableStyle, PARAGRAPH_MIN_HEIGHT,
|
||||
},
|
||||
};
|
||||
use warp_util::user_input::UserInput;
|
||||
use warpui::{elements::Border, fonts::FamilyId, ui_components::checkbox::HOVER_BACKGROUND_COLOR};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
notebooks::editor::embedded_item::EmbeddedWorkflow,
|
||||
settings::{derived_notebook_font_size, FontSettings},
|
||||
themes::theme::Fill,
|
||||
ui_components::icons::Icon,
|
||||
util::color::{ContrastingColor, MinimumAllowedContrast},
|
||||
workflows::{CloudWorkflow, WorkflowSource, WorkflowType},
|
||||
};
|
||||
|
||||
mod block_insertion_menu;
|
||||
mod embedded_item;
|
||||
mod embedding_model;
|
||||
mod find_bar;
|
||||
mod interaction_state_model;
|
||||
pub mod keys;
|
||||
mod link_editor;
|
||||
pub mod model;
|
||||
pub mod notebook_command;
|
||||
mod omnibar;
|
||||
pub mod view;
|
||||
|
||||
pub use block_insertion_menu::BlockInsertionSource;
|
||||
use warpui::elements::ListIndentLevel;
|
||||
|
||||
const NOTEBOOK_LINE_HEIGHT_RATIO: f32 = 1.6;
|
||||
const NOTEBOOK_BASELINE_RATIO: f32 = 0.7;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct MarkdownTableAppearance {
|
||||
pub border_color: ColorU,
|
||||
pub header_background: ColorU,
|
||||
pub cell_background: ColorU,
|
||||
pub alternate_row_background: Option<ColorU>,
|
||||
pub text_color: ColorU,
|
||||
pub header_text_color: ColorU,
|
||||
pub scrollbar_nonactive_thumb_color: ColorU,
|
||||
pub scrollbar_active_thumb_color: ColorU,
|
||||
pub cell_padding: f32,
|
||||
pub outer_border: bool,
|
||||
pub column_dividers: bool,
|
||||
pub row_dividers: bool,
|
||||
}
|
||||
|
||||
/// A kind of block that can be added to a notebook.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockType {
|
||||
RunnableCommand,
|
||||
Code,
|
||||
Header(BlockHeaderSize),
|
||||
Text,
|
||||
UnorderedList,
|
||||
OrderedList,
|
||||
TaskList,
|
||||
}
|
||||
|
||||
impl BlockType {
|
||||
const ALL: [BlockType; 12] = [
|
||||
BlockType::RunnableCommand,
|
||||
BlockType::Code,
|
||||
BlockType::Header(BlockHeaderSize::Header1),
|
||||
BlockType::Header(BlockHeaderSize::Header2),
|
||||
BlockType::Header(BlockHeaderSize::Header3),
|
||||
BlockType::Header(BlockHeaderSize::Header4),
|
||||
BlockType::Header(BlockHeaderSize::Header5),
|
||||
BlockType::Header(BlockHeaderSize::Header6),
|
||||
BlockType::Text,
|
||||
BlockType::UnorderedList,
|
||||
BlockType::OrderedList,
|
||||
BlockType::TaskList,
|
||||
];
|
||||
|
||||
fn all() -> impl Iterator<Item = Self> {
|
||||
Self::ALL.into_iter()
|
||||
}
|
||||
|
||||
/// Block types that behave as code:
|
||||
/// * [`BlockType::RunnableCommand`]
|
||||
/// * [`BlockType::Code`]
|
||||
///
|
||||
/// These types support multiple paragraphs and syntax highlighting, but not user-defined
|
||||
/// formatting. In the block insertion menu, these types are grouped together.
|
||||
fn code_block_types() -> impl Iterator<Item = Self> {
|
||||
[BlockType::RunnableCommand, BlockType::Code].into_iter()
|
||||
}
|
||||
|
||||
/// Block types that behave as text (plain text, headings, and lists). These types support
|
||||
/// user-defined formatting. In the block insertion menu, these types are grouped together.
|
||||
fn text_block_types() -> impl Iterator<Item = Self> {
|
||||
Self::all().filter(|block_type| {
|
||||
*block_type != BlockType::Code && *block_type != BlockType::RunnableCommand
|
||||
})
|
||||
}
|
||||
|
||||
fn icon(self) -> Icon {
|
||||
match self {
|
||||
BlockType::Text => Icon::TextBlock,
|
||||
BlockType::Header(_) => Icon::HeaderBlock,
|
||||
BlockType::RunnableCommand => Icon::RunnableCommandBlock,
|
||||
BlockType::Code => Icon::Code1,
|
||||
BlockType::UnorderedList => Icon::BulletedListBlock,
|
||||
BlockType::OrderedList => Icon::OrderedListBlock,
|
||||
BlockType::TaskList => Icon::TaskListBlock,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_color(self, appearance: &Appearance) -> Option<Fill> {
|
||||
match self {
|
||||
BlockType::Text
|
||||
| BlockType::Header(_)
|
||||
| BlockType::UnorderedList
|
||||
| BlockType::OrderedList
|
||||
| BlockType::TaskList => Some(Fill::Solid(appearance.theme().ui_warning_color())),
|
||||
BlockType::RunnableCommand | BlockType::Code => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
BlockType::Text => "Text",
|
||||
BlockType::Header(size) => size.label(),
|
||||
BlockType::RunnableCommand => "Command",
|
||||
BlockType::UnorderedList => "Bulleted list",
|
||||
BlockType::OrderedList => "Numbered list",
|
||||
BlockType::Code => "Code",
|
||||
BlockType::TaskList => "To-do list",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The embedded item transformation for notebooks.
|
||||
pub(super) fn notebook_embedded_item_conversion(
|
||||
mut mapping: serde_yaml::Mapping,
|
||||
) -> Option<Arc<dyn EmbeddedItem>> {
|
||||
use serde_yaml::Value;
|
||||
match mapping.remove(&Value::String("id".to_string())) {
|
||||
Some(Value::String(hashed_id)) => Some(Arc::new(EmbeddedWorkflow::new(hashed_id))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn markdown_table_appearance(appearance: &Appearance) -> MarkdownTableAppearance {
|
||||
let theme = appearance.theme();
|
||||
MarkdownTableAppearance {
|
||||
border_color: internal_colors::neutral_4(theme),
|
||||
header_background: ColorU::transparent_black(),
|
||||
cell_background: ColorU::transparent_black(),
|
||||
alternate_row_background: None,
|
||||
text_color: internal_colors::text_sub(theme, theme.background()),
|
||||
header_text_color: internal_colors::text_main(theme, theme.background()),
|
||||
scrollbar_nonactive_thumb_color: theme.nonactive_ui_detail().into_solid(),
|
||||
scrollbar_active_thumb_color: theme.active_ui_detail().into_solid(),
|
||||
cell_padding: 12.,
|
||||
outer_border: false,
|
||||
column_dividers: false,
|
||||
row_dividers: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn markdown_table_style(
|
||||
appearance: &Appearance,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
) -> TableStyle {
|
||||
let table_appearance = markdown_table_appearance(appearance);
|
||||
TableStyle {
|
||||
border_color: table_appearance.border_color,
|
||||
header_background: table_appearance.header_background,
|
||||
cell_background: table_appearance.cell_background,
|
||||
alternate_row_background: table_appearance.alternate_row_background,
|
||||
text_color: table_appearance.text_color,
|
||||
header_text_color: table_appearance.header_text_color,
|
||||
scrollbar_nonactive_thumb_color: table_appearance.scrollbar_nonactive_thumb_color,
|
||||
scrollbar_active_thumb_color: table_appearance.scrollbar_active_thumb_color,
|
||||
font_family,
|
||||
font_size,
|
||||
cell_padding: table_appearance.cell_padding,
|
||||
outer_border: table_appearance.outer_border,
|
||||
column_dividers: table_appearance.column_dividers,
|
||||
row_dividers: table_appearance.row_dividers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build [`RichTextStyles`] based on the current [`Appearance`].
|
||||
pub fn rich_text_styles(appearance: &Appearance, font_settings: &FontSettings) -> RichTextStyles {
|
||||
let theme = appearance.theme();
|
||||
let inline_font_color: ColorU = theme.terminal_colors().normal.red.into();
|
||||
let font_size = derived_notebook_font_size(font_settings);
|
||||
RichTextStyles {
|
||||
base_text: ParagraphStyles {
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
font_family: appearance.ui_font_family(),
|
||||
text_color: theme.main_text_color(theme.background()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
code_text: ParagraphStyles {
|
||||
font_family: appearance.monospace_font_family(),
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
text_color: theme.main_text_color(theme.background()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
fixed_width_tab_size: Some(4),
|
||||
},
|
||||
code_background: theme.background().into(),
|
||||
embedding_background: theme.surface_2().into(),
|
||||
embedding_text: ParagraphStyles {
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
font_family: appearance.monospace_font_family(),
|
||||
text_color: theme.main_text_color(theme.surface_2()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
fixed_width_tab_size: Some(4),
|
||||
},
|
||||
code_border: Border::all(1.).with_border_fill(theme.surface_3()),
|
||||
placeholder_color: appearance
|
||||
.theme()
|
||||
.hint_text_color(theme.background())
|
||||
.into_solid(),
|
||||
selection_fill: appearance.theme().text_selection_color().into(),
|
||||
cursor_fill: theme
|
||||
.cursor()
|
||||
.on_background(theme.background(), MinimumAllowedContrast::Text)
|
||||
.into(),
|
||||
inline_code_style: InlineCodeStyle {
|
||||
font_family: appearance.monospace_font_family(),
|
||||
background: theme.surface_3().into(),
|
||||
font_color: inline_font_color
|
||||
.on_background(theme.surface_3().into(), MinimumAllowedContrast::Text),
|
||||
},
|
||||
check_box_style: CheckBoxStyle {
|
||||
border_color: theme.foreground().into(),
|
||||
border_width: 2.,
|
||||
icon_path: CHECK_SVG_PATH,
|
||||
background: theme.accent().into(),
|
||||
hover_background: *HOVER_BACKGROUND_COLOR,
|
||||
},
|
||||
horizontal_rule_style: HorizontalRuleStyle {
|
||||
color: theme.surface_3().into(),
|
||||
rule_height: 3.,
|
||||
},
|
||||
broken_link_style: BrokenLinkStyle {
|
||||
icon_path: "bundled/svg/link-broken-02.svg",
|
||||
icon_color: theme.terminal_colors().normal.red.into(),
|
||||
},
|
||||
block_spacings: Default::default(),
|
||||
show_placeholder_text_on_empty_block: true,
|
||||
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
|
||||
cursor_width: 1.,
|
||||
highlight_urls: true,
|
||||
table_style: markdown_table_style(appearance, appearance.ui_font_family(), font_size),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockType> for BufferBlockStyle {
|
||||
fn from(block_type: BlockType) -> Self {
|
||||
match block_type {
|
||||
BlockType::RunnableCommand => Self::CodeBlock {
|
||||
code_block_type: CodeBlockType::Shell,
|
||||
},
|
||||
BlockType::Text => Self::PlainText,
|
||||
BlockType::Header(header_size) => Self::Header { header_size },
|
||||
BlockType::UnorderedList => Self::UnorderedList {
|
||||
indent_level: ListIndentLevel::One,
|
||||
},
|
||||
BlockType::OrderedList => Self::ordered_list(ListIndentLevel::One),
|
||||
BlockType::Code => Self::CodeBlock {
|
||||
code_block_type: CodeBlockType::Code {
|
||||
lang: CODE_BLOCK_DEFAULT_MARKDOWN_LANG.into(),
|
||||
},
|
||||
},
|
||||
BlockType::TaskList => Self::TaskList {
|
||||
indent_level: ListIndentLevel::One,
|
||||
complete: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a ContentBlockType> for BlockType {
|
||||
fn from(block_type: &'a ContentBlockType) -> Self {
|
||||
match block_type {
|
||||
// TODO: Add support for block item here.
|
||||
ContentBlockType::Item(_) => BlockType::Text,
|
||||
ContentBlockType::Text(block_style) => Self::from(block_style),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a BufferBlockStyle> for BlockType {
|
||||
fn from(block_style: &'a BufferBlockStyle) -> Self {
|
||||
match block_style {
|
||||
BufferBlockStyle::CodeBlock { code_block_type } => match code_block_type {
|
||||
CodeBlockType::Shell => BlockType::RunnableCommand,
|
||||
CodeBlockType::Mermaid | CodeBlockType::Code { .. } => BlockType::Code,
|
||||
},
|
||||
BufferBlockStyle::PlainText => BlockType::Text,
|
||||
BufferBlockStyle::Header { header_size } => BlockType::Header(*header_size),
|
||||
BufferBlockStyle::UnorderedList { .. } => BlockType::UnorderedList,
|
||||
BufferBlockStyle::OrderedList { .. } => BlockType::OrderedList,
|
||||
BufferBlockStyle::TaskList { .. } => BlockType::TaskList,
|
||||
BufferBlockStyle::Table { .. } => BlockType::Text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper around the shared [`Workflow`] type with additional context for workflows contained
|
||||
/// within a notebook.
|
||||
///
|
||||
/// This may be a command block that's part of the notebook text, or an embedded Warp Drive workflow.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NotebookWorkflow {
|
||||
/// Definition of the workflow itself.
|
||||
pub workflow: UserInput<Arc<WorkflowType>>,
|
||||
/// The source of the workflow, for attribution. If `None`, the workflow should be attributed
|
||||
/// to the parent notebook.
|
||||
pub source: Option<WorkflowSource>,
|
||||
}
|
||||
|
||||
impl NotebookWorkflow {
|
||||
pub fn from_cloud_workflow(cloud_workflow: Box<CloudWorkflow>) -> Self {
|
||||
Self {
|
||||
source: Some(cloud_workflow.permissions.owner.into()),
|
||||
workflow: UserInput::new(Arc::new(WorkflowType::Cloud(cloud_workflow))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the [`WorkflowType`], assigning a name using the given callback if needed.
|
||||
pub fn named_workflow<F: FnOnce() -> Option<String>>(&self, name: F) -> Arc<WorkflowType> {
|
||||
match &**self.workflow {
|
||||
WorkflowType::Notebook(workflow) if workflow.name().is_empty() => match name() {
|
||||
Some(name) => {
|
||||
let mut workflow = workflow.clone();
|
||||
workflow.set_name(name.as_str());
|
||||
Arc::new(WorkflowType::Notebook(workflow))
|
||||
}
|
||||
None => (*self.workflow).clone(),
|
||||
},
|
||||
_ => (*self.workflow).clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,818 @@
|
||||
use std::{borrow::Cow, mem, ops::Range, sync::Arc};
|
||||
|
||||
use async_channel::Sender;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use pathfinder_color::ColorU;
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use syntect::{
|
||||
easy::HighlightLines,
|
||||
highlighting::{self, Theme, ThemeSet},
|
||||
parsing::SyntaxSet,
|
||||
util::LinesWithEndings,
|
||||
};
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warp_editor::{
|
||||
content::{
|
||||
anchor::Anchor,
|
||||
buffer::{Buffer, BufferEvent, EditOrigin},
|
||||
selection_model::BufferSelectionModel,
|
||||
text::{
|
||||
BlockType, BufferBlockStyle, CodeBlockType, CODE_BLOCK_DEFAULT_DISPLAY_LANG,
|
||||
CODE_BLOCK_SHELL_DISPLAY_LANG,
|
||||
},
|
||||
},
|
||||
editor::RunnableCommandModel,
|
||||
};
|
||||
|
||||
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
|
||||
use warp_util::user_input::UserInput;
|
||||
use warpui::{elements::Align, r#async::SpawnedFutureHandle, AppContext};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment, MouseStateHandle,
|
||||
ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::Properties,
|
||||
presenter::ChildView,
|
||||
Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, ViewHandle,
|
||||
WeakModelHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
completer::SessionAgnosticContext,
|
||||
debounce::debounce,
|
||||
drive::workflows::arguments::ArgumentsState,
|
||||
editor::InteractionState,
|
||||
notebooks::{
|
||||
styles::block_footer_action_button,
|
||||
telemetry::{ActionEntrypoint, BlockInfo},
|
||||
},
|
||||
settings::FontSettings,
|
||||
terminal::input::{
|
||||
decorations::{parse_current_commands_and_tokens, ParsedTokenData, ParsedTokensSnapshot},
|
||||
DEBOUNCE_INPUT_DECORATION_PERIOD,
|
||||
},
|
||||
themes::theme::{AnsiColorIdentifier, AnsiColors},
|
||||
ui_components::icons::Icon,
|
||||
util::{
|
||||
bindings::CustomAction,
|
||||
color::{ContrastingColor, MinimumAllowedContrast},
|
||||
},
|
||||
view_components::{Dropdown, DropdownItem},
|
||||
workflows::{workflow::Workflow, WorkflowType},
|
||||
Assets,
|
||||
};
|
||||
|
||||
use super::{
|
||||
interaction_state_model::InteractionStateModel,
|
||||
keys::{custom_action_to_display, NotebookKeybindings},
|
||||
model::ChildModelHandle,
|
||||
rich_text_styles,
|
||||
view::EditorViewAction,
|
||||
NotebookWorkflow,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref SUPPORTED_LANGUAGES: &'static [&'static str] = &[
|
||||
"Go",
|
||||
"Java",
|
||||
"C++",
|
||||
"C#",
|
||||
"JavaScript",
|
||||
"Python",
|
||||
"Ruby on Rails",
|
||||
"Rust",
|
||||
"SQL",
|
||||
"YAML",
|
||||
"JSON",
|
||||
"PHP",
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
insert_button_state: MouseStateHandle,
|
||||
copy_button_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
struct CachedHighlightKey {
|
||||
buffer_content: String,
|
||||
style: CodeBlockType,
|
||||
}
|
||||
|
||||
struct CachedHighlightColors {
|
||||
key: CachedHighlightKey,
|
||||
colors: Vec<(Range<ByteOffset>, AnsiColorIdentifier)>,
|
||||
}
|
||||
|
||||
impl CachedHighlightColors {
|
||||
fn matches_key(&self, buffer_content: &str, style: CodeBlockType) -> bool {
|
||||
self.key.buffer_content == buffer_content && self.key.style == style
|
||||
}
|
||||
}
|
||||
|
||||
struct CodeHighlightResult {
|
||||
origin_text: String,
|
||||
colors: Vec<(Range<ByteOffset>, AnsiColorIdentifier)>,
|
||||
}
|
||||
|
||||
/// Runnable command behavior for notebooks.
|
||||
pub struct NotebookCommand {
|
||||
start: Anchor,
|
||||
end: Anchor,
|
||||
interaction_state: ModelHandle<InteractionStateModel>,
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
is_selected: bool,
|
||||
block_type_dropdown: ViewHandle<Dropdown<EditorViewAction>>,
|
||||
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
debounce_highlighting_tx: Sender<()>,
|
||||
syntax_highlighting_handle: Option<SpawnedFutureHandle>,
|
||||
cached_highlight_delta: Option<CachedHighlightColors>,
|
||||
|
||||
syntax_config: Option<(SyntaxSet, Theme)>,
|
||||
|
||||
handle: WeakModelHandle<Self>,
|
||||
}
|
||||
|
||||
impl NotebookCommand {
|
||||
/// Create a new `NotebookCommand` model to back the runnable command between `start` and `end`.
|
||||
pub fn new(
|
||||
start: CharOffset,
|
||||
end: CharOffset,
|
||||
interaction_state: ModelHandle<InteractionStateModel>,
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
rte_window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let current_block_style =
|
||||
NotebookCommand::block_type_to_code_type(content.as_ref(ctx).block_type_at_point(end));
|
||||
let (start, end) = selection_model.update(ctx, |selection_model, ctx| {
|
||||
(
|
||||
selection_model.anchor(start, ctx),
|
||||
selection_model.anchor(end, ctx),
|
||||
)
|
||||
});
|
||||
|
||||
let block_type_dropdown = ctx.add_typed_action_view(rte_window_id, |ctx| {
|
||||
let mut dropdown = Dropdown::new(ctx);
|
||||
|
||||
dropdown.set_top_bar_max_width(68.);
|
||||
dropdown.set_menu_width(68., ctx);
|
||||
|
||||
dropdown.add_items(
|
||||
CodeBlockType::all()
|
||||
.map(|code_block_type| {
|
||||
DropdownItem::new(
|
||||
code_block_type.to_string().as_str(),
|
||||
EditorViewAction::CodeBlockTypeSelectedAtOffset {
|
||||
code_block_type,
|
||||
start_anchor: start.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
let current_dropdown_selection = match ¤t_block_style {
|
||||
CodeBlockType::Shell => CODE_BLOCK_SHELL_DISPLAY_LANG,
|
||||
CodeBlockType::Mermaid => "Mermaid",
|
||||
CodeBlockType::Code { lang } if lang == "text" => CODE_BLOCK_DEFAULT_DISPLAY_LANG,
|
||||
CodeBlockType::Code { lang } => lang,
|
||||
};
|
||||
dropdown.set_selected_by_name(current_dropdown_selection, ctx);
|
||||
|
||||
dropdown
|
||||
});
|
||||
|
||||
let syntax_config = {
|
||||
let ps = SyntaxSet::load_defaults_newlines();
|
||||
if let Some(asset) = Assets::get("bundled/syntax_theme/base16.tmTheme") {
|
||||
let binary = asset.data;
|
||||
let mut cursor = std::io::Cursor::new(binary);
|
||||
match ThemeSet::load_from_reader(&mut cursor) {
|
||||
Ok(theme) => Some((ps, theme)),
|
||||
Err(e) => {
|
||||
log::debug!("Failed to load theme set from asset: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
ctx.subscribe_to_model(&content, Self::on_buffer_content_updated);
|
||||
|
||||
let (debounce_highlighting_tx, debounce_highlighting_rx) = async_channel::unbounded();
|
||||
let _ = ctx.spawn_stream_local(
|
||||
debounce(DEBOUNCE_INPUT_DECORATION_PERIOD, debounce_highlighting_rx),
|
||||
|me, _, ctx| me.highlight_syntax(ctx),
|
||||
|_me, _ctx| {},
|
||||
);
|
||||
|
||||
let mut command = Self {
|
||||
start,
|
||||
end,
|
||||
interaction_state,
|
||||
content,
|
||||
selection_model,
|
||||
mouse_state_handles: Default::default(),
|
||||
is_selected: false,
|
||||
block_type_dropdown,
|
||||
syntax_highlighting_handle: None,
|
||||
cached_highlight_delta: None,
|
||||
debounce_highlighting_tx,
|
||||
syntax_config,
|
||||
handle: ctx.handle(),
|
||||
};
|
||||
|
||||
command.highlight_syntax(ctx);
|
||||
command
|
||||
}
|
||||
|
||||
fn block_type_to_code_type(block_type: BlockType) -> CodeBlockType {
|
||||
match block_type {
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock {
|
||||
code_block_type: CodeBlockType::Shell,
|
||||
}) => CodeBlockType::Shell,
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock {
|
||||
code_block_type: CodeBlockType::Mermaid,
|
||||
}) => CodeBlockType::Mermaid,
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock {
|
||||
code_block_type: CodeBlockType::Code { lang },
|
||||
}) if SUPPORTED_LANGUAGES.contains(&lang.as_str()) => CodeBlockType::Code { lang },
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock { .. }) => CodeBlockType::Code {
|
||||
lang: CODE_BLOCK_DEFAULT_MARKDOWN_LANG.to_string(),
|
||||
},
|
||||
_ => Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn start_anchor(&self) -> Anchor {
|
||||
self.start.clone()
|
||||
}
|
||||
|
||||
// Returns the CodeBlockType of this command
|
||||
fn code_block_type(&self, ctx: &AppContext) -> CodeBlockType {
|
||||
if let Some(offset) = self.end_offset(ctx) {
|
||||
NotebookCommand::block_type_to_code_type(
|
||||
self.content.as_ref(ctx).block_type_at_point(offset),
|
||||
)
|
||||
} else {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn syntax_highlighting_handle(&self) -> Option<SpawnedFutureHandle> {
|
||||
self.syntax_highlighting_handle.clone()
|
||||
}
|
||||
|
||||
pub fn highlight_syntax(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(handle) = self.syntax_highlighting_handle.take() {
|
||||
handle.abort_handle().abort();
|
||||
}
|
||||
|
||||
let success = self.try_apply_cached_highlighting(ctx);
|
||||
if success {
|
||||
return;
|
||||
}
|
||||
|
||||
let code_block_type = self.code_block_type(ctx);
|
||||
let Some(buffer_text) = self.command(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match code_block_type {
|
||||
CodeBlockType::Shell => {
|
||||
let completion_context =
|
||||
SessionAgnosticContext::new(CommandRegistry::global_instance());
|
||||
self.syntax_highlighting_handle = Some(ctx.spawn(
|
||||
async move {
|
||||
parse_current_commands_and_tokens(buffer_text, &completion_context).await
|
||||
},
|
||||
|notebook_command, parsed_tokens, ctx| {
|
||||
notebook_command.update_buffer_with_parsed_tokens(parsed_tokens, ctx);
|
||||
},
|
||||
));
|
||||
}
|
||||
CodeBlockType::Mermaid => (),
|
||||
// Skip highlighting for default code.
|
||||
CodeBlockType::Code { lang } if lang == "text" => (),
|
||||
CodeBlockType::Code { lang } => {
|
||||
let Some((syntax_set, syntax_theme)) = self.syntax_config.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.syntax_highlighting_handle = Some(ctx.spawn(
|
||||
parse_code_into_style_ranges(buffer_text, lang, syntax_set, syntax_theme),
|
||||
|notebook_command, result, ctx| {
|
||||
notebook_command.update_buffer_with_parsed_code_syntax(result, ctx);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_buffer_with_parsed_code_syntax(
|
||||
&mut self,
|
||||
highlight_result: Option<CodeHighlightResult>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(highlight_result) = highlight_result else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.maybe_apply_highlighting(
|
||||
CachedHighlightKey {
|
||||
buffer_content: highlight_result.origin_text,
|
||||
style: self.code_block_type(ctx),
|
||||
},
|
||||
highlight_result.colors,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
fn update_buffer_with_parsed_tokens(
|
||||
&mut self,
|
||||
parsed_tokens: ParsedTokensSnapshot,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let colors = parsed_token_to_color_style_ranges(parsed_tokens.parsed_tokens);
|
||||
|
||||
self.maybe_apply_highlighting(
|
||||
CachedHighlightKey {
|
||||
buffer_content: parsed_tokens.buffer_text,
|
||||
style: CodeBlockType::Shell,
|
||||
},
|
||||
colors,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn try_apply_cached_highlighting(&self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
let code_block_type = self.code_block_type(ctx);
|
||||
let Some(buffer_text) = self.command(ctx) else {
|
||||
return false;
|
||||
};
|
||||
match &self.cached_highlight_delta {
|
||||
// If the command block content matches our cache, simply update with the cache.
|
||||
Some(cache) if cache.matches_key(&buffer_text, code_block_type) => {
|
||||
if let Some(block_start) = self.start_offset(ctx) {
|
||||
self.apply_highlighting_to_buffer(&cache.colors, block_start, ctx)
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write syntax highlighting colors into the buffer and cache them with the given key. If the
|
||||
/// key does not match the buffer state, or the backing content range has been unstyled, the
|
||||
/// highlighting is discarded.
|
||||
fn maybe_apply_highlighting(
|
||||
&mut self,
|
||||
key: CachedHighlightKey,
|
||||
colors: Vec<(Range<ByteOffset>, AnsiColorIdentifier)>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(buffer_text) = self.command(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If the command text has changed from when we parsed it, discard the parsing result.
|
||||
if buffer_text != key.buffer_content {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(block_start) = self.start_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If the text range is no longer a code block, do not try to highlight it.
|
||||
if !matches!(
|
||||
self.content
|
||||
.as_ref(ctx)
|
||||
.block_type_at_point(block_start + 1),
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock { .. })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.apply_highlighting_to_buffer(&colors, block_start, ctx);
|
||||
self.cached_highlight_delta = Some(CachedHighlightColors { key, colors });
|
||||
}
|
||||
|
||||
fn apply_highlighting_to_buffer(
|
||||
&self,
|
||||
colors: &[(Range<ByteOffset>, AnsiColorIdentifier)],
|
||||
block_start: CharOffset,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let font_settings = FontSettings::as_ref(ctx);
|
||||
let terminal_colors_normal = appearance.theme().terminal_colors().normal.to_owned();
|
||||
let background_color = rich_text_styles(appearance, font_settings)
|
||||
.code_background
|
||||
.start_color();
|
||||
|
||||
let transformed_colors =
|
||||
transform_ansi_color_to_solid_color(colors, &terminal_colors_normal, background_color);
|
||||
|
||||
self.content.update(ctx, |buffer, ctx| {
|
||||
buffer.color_code_block_ranges(
|
||||
block_start + 1,
|
||||
&transformed_colors,
|
||||
self.selection_model.clone(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_buffer_content_updated(&mut self, event: &BufferEvent, ctx: &mut ModelContext<Self>) {
|
||||
// If the buffer changes, check to see if we should update the dropdown
|
||||
match event {
|
||||
BufferEvent::ContentChanged { origin, delta, .. }
|
||||
if *origin != EditOrigin::SystemEdit =>
|
||||
{
|
||||
let code_block_type = self.code_block_type(ctx);
|
||||
self.block_type_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_selected_by_name(code_block_type.to_string(), ctx)
|
||||
});
|
||||
let replacement_offset = &delta.old_offset;
|
||||
|
||||
let Some(start_offset) = self.start_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !matches!(
|
||||
self.content
|
||||
.as_ref(ctx)
|
||||
.block_type_at_point(start_offset + 1),
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock { .. })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(end_offset) = self.end_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If the replacement range overlaps with command block range, regenerate the highlight.
|
||||
if start_offset <= replacement_offset.end && end_offset >= replacement_offset.start
|
||||
{
|
||||
// In tests, run syntax highlighting immediately.
|
||||
// TODO(ben): This is another case where mock timers in tests would be
|
||||
// helpful.
|
||||
#[cfg(test)]
|
||||
self.highlight_syntax(ctx);
|
||||
#[cfg(not(test))]
|
||||
let _ = self.debounce_highlighting_tx.try_send(());
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// The offset of this command's start marker.
|
||||
pub fn start_offset(&self, ctx: &impl ModelAsRef) -> Option<CharOffset> {
|
||||
self.selection_model.as_ref(ctx).resolve_anchor(&self.start)
|
||||
}
|
||||
|
||||
/// The offset of this command's end marker.
|
||||
pub fn end_offset(&self, ctx: &impl ModelAsRef) -> Option<CharOffset> {
|
||||
self.selection_model.as_ref(ctx).resolve_anchor(&self.end)
|
||||
}
|
||||
|
||||
/// The current text of this command.
|
||||
pub fn command(&self, ctx: &impl ModelAsRef) -> Option<String> {
|
||||
let start = self.start_offset(ctx)?;
|
||||
let end = self.end_offset(ctx)?;
|
||||
// Add 1 to start because it refers to the start marker offset.
|
||||
Some(
|
||||
self.content
|
||||
.as_ref(ctx)
|
||||
.text_in_range(start + 1..end)
|
||||
.into_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_dropdown_focused(&self, ctx: &AppContext) -> bool {
|
||||
self.block_type_dropdown.as_ref(ctx).is_focused(ctx)
|
||||
}
|
||||
|
||||
/// Whether or not this block contains the text cursor
|
||||
pub fn contains_cursor(&self, ctx: &impl ModelAsRef) -> bool {
|
||||
let cursor = self.selection_model.as_ref(ctx).first_selection_head();
|
||||
// Subtract one to get to the start marker of the block
|
||||
let block_start = self.content.as_ref(ctx).block_or_line_start(cursor) - 1;
|
||||
if let Some(start_offset) = self.start_offset(ctx) {
|
||||
start_offset == block_start
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether or not we should display the dropdown selector for this block. Essentially, we want to display
|
||||
/// it if the editor if the user has the command selected, or they are typing in it.
|
||||
fn should_display_block_type_dropdown(
|
||||
&self,
|
||||
editor_is_focused: bool,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
// If we are in view mode or the editor is not focused, return false
|
||||
if !matches!(
|
||||
self.interaction_state.as_ref(ctx).interaction_state(),
|
||||
InteractionState::Editable
|
||||
) || !editor_is_focused
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this block is selected, return true
|
||||
if self.is_selected() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If this block contains the cursor, and another block is not selected, return true
|
||||
if self.contains_cursor(ctx) && !self.interaction_state.as_ref(ctx).is_block_selected() {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this block is selected.
|
||||
pub fn is_selected(&self) -> bool {
|
||||
self.is_selected
|
||||
}
|
||||
|
||||
/// Set whether or not this block is selected.
|
||||
pub fn set_selected(&mut self, selected: bool) -> bool {
|
||||
mem::replace(&mut self.is_selected, selected)
|
||||
}
|
||||
|
||||
/// Promotes this notebook command into a [`Workflow`]. If the workflow is anonymous, the
|
||||
/// containing `NotebookView` fills in its title.
|
||||
pub fn to_workflow(&self, ctx: &AppContext) -> Option<NotebookWorkflow> {
|
||||
let command = self.command(ctx)?;
|
||||
let args_state = ArgumentsState::for_command_workflow(&Default::default(), command.clone());
|
||||
// TODO: Once notebook workflows have their own metadata, we can populate the title here.
|
||||
let workflow = Workflow::new(String::new(), command).with_arguments(args_state.arguments);
|
||||
Some(NotebookWorkflow {
|
||||
workflow: UserInput::new(Arc::new(WorkflowType::Notebook(workflow))),
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NotebookCommand {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl RunnableCommandModel for NotebookCommand {
|
||||
fn render_block_footer(&self, editor_is_focused: bool, ctx: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let mut model = self.handle.clone();
|
||||
|
||||
// Get the CodeBlockType at the end offset for the NotebookCommand
|
||||
// We would expect the BlockStyle at the offset to be a CodeBlock
|
||||
let block_style = self.code_block_type(ctx);
|
||||
|
||||
let mut footer = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End);
|
||||
|
||||
if self.should_display_block_type_dropdown(editor_is_focused, ctx) {
|
||||
footer.add_child(ChildView::new(&self.block_type_dropdown).finish());
|
||||
} else {
|
||||
footer.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
self.code_block_type(ctx).to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_style(Properties {
|
||||
weight: warpui::fonts::Weight::Light,
|
||||
..Default::default()
|
||||
})
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(11.)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
footer.add_child(Shrinkable::new(1.0, Empty::new().finish()).finish());
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::Copy,
|
||||
self.mouse_state_handles.copy_button_state.clone(),
|
||||
"Copy",
|
||||
custom_action_to_display(CustomAction::Copy),
|
||||
)
|
||||
.on_click(move |ctx, app, _| {
|
||||
if let Some(command_model) = model.upgrade(app) {
|
||||
if let Some(block_content) = command_model.as_ref(app).command(app) {
|
||||
ctx.dispatch_typed_action(EditorViewAction::CopyTextToClipboard {
|
||||
text: UserInput::new(block_content.trim()),
|
||||
block: BlockInfo::CodeBlock,
|
||||
entrypoint: ActionEntrypoint::Button,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
if matches!(block_style, CodeBlockType::Shell) {
|
||||
model = self.handle.clone();
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::TerminalInput,
|
||||
self.mouse_state_handles.insert_button_state.clone(),
|
||||
"Run in terminal",
|
||||
NotebookKeybindings::as_ref(ctx).run_commands_keybinding(),
|
||||
)
|
||||
.on_click(move |ctx, app, _| {
|
||||
if let Some(command_model) = model.upgrade(app) {
|
||||
if let Some(workflow) = command_model.as_ref(app).to_workflow(app) {
|
||||
ctx.dispatch_typed_action(EditorViewAction::RunWorkflow(workflow));
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
footer.finish()
|
||||
}
|
||||
|
||||
fn border(&self, app: &AppContext) -> Option<Border> {
|
||||
if self.is_selected {
|
||||
let border_fill = Appearance::as_ref(app).theme().accent();
|
||||
Some(Border::all(3.).with_border_fill(border_fill))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ChildModelHandle for ModelHandle<NotebookCommand> {
|
||||
fn start_offset(&self, app: &AppContext) -> Option<CharOffset> {
|
||||
self.as_ref(app).start_offset(app)
|
||||
}
|
||||
|
||||
fn end_offset(&self, app: &AppContext) -> Option<CharOffset> {
|
||||
self.as_ref(app).end_offset(app)
|
||||
}
|
||||
|
||||
fn selectable(&self, _: &AppContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn executable_workflow(&self, app: &AppContext) -> Option<NotebookWorkflow> {
|
||||
self.as_ref(app).to_workflow(app)
|
||||
}
|
||||
|
||||
fn executable_command<'a>(&'a self, app: &'a AppContext) -> Option<Cow<'a, str>> {
|
||||
self.as_ref(app).command(app).map(Into::into)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn selected(&self, app: &AppContext) -> bool {
|
||||
self.as_ref(app).is_selected
|
||||
}
|
||||
|
||||
fn set_selected(&self, selected: bool, ctx: &mut AppContext) -> bool {
|
||||
self.update(ctx, |model, _ctx| model.set_selected(selected))
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn ChildModelHandle> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// Parse code into style ranges based on the current ANSI color and language.
|
||||
async fn parse_code_into_style_ranges(
|
||||
buffer_text: String,
|
||||
language: String,
|
||||
syntax_set: SyntaxSet,
|
||||
theme: Theme,
|
||||
) -> Option<CodeHighlightResult> {
|
||||
// Find the syntax corresponding to the input language.
|
||||
let syntax = syntax_set.find_syntax_by_name(&language)?;
|
||||
|
||||
let mut h = HighlightLines::new(syntax, &theme);
|
||||
let mut runs = Vec::new();
|
||||
|
||||
let mut byte_offset = 0;
|
||||
for line in LinesWithEndings::from(&buffer_text) {
|
||||
let ranges = h.highlight_line(line, &syntax_set).ok()?;
|
||||
|
||||
for (text_style, content) in ranges {
|
||||
let text_color = text_style.foreground;
|
||||
let text_len = content.len();
|
||||
|
||||
if let Some(ansi_color) = to_ansi_color(text_color) {
|
||||
runs.push((
|
||||
ByteOffset::from(byte_offset)..ByteOffset::from(byte_offset + text_len),
|
||||
ansi_color,
|
||||
));
|
||||
}
|
||||
|
||||
byte_offset += text_len;
|
||||
}
|
||||
}
|
||||
|
||||
Some(CodeHighlightResult {
|
||||
origin_text: buffer_text,
|
||||
colors: runs,
|
||||
})
|
||||
}
|
||||
|
||||
// We use base16 theme here so the colors could translate fully to terminal ANSI color.
|
||||
pub fn to_ansi_color(color: highlighting::Color) -> Option<AnsiColorIdentifier> {
|
||||
match color.r {
|
||||
0x00 => Some(AnsiColorIdentifier::Black),
|
||||
0x01 => Some(AnsiColorIdentifier::Red),
|
||||
0x02 => Some(AnsiColorIdentifier::Green),
|
||||
0x03 => Some(AnsiColorIdentifier::Yellow),
|
||||
0x04 => Some(AnsiColorIdentifier::Blue),
|
||||
0x05 => Some(AnsiColorIdentifier::Magenta),
|
||||
0x06 => Some(AnsiColorIdentifier::Cyan),
|
||||
0x07 => Some(AnsiColorIdentifier::White),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parsed_token_to_color_style_ranges(
|
||||
parsed_tokens: Vec<ParsedTokenData>,
|
||||
) -> Vec<(Range<ByteOffset>, AnsiColorIdentifier)> {
|
||||
let mut colors = Vec::new();
|
||||
|
||||
for token_data in parsed_tokens {
|
||||
let token_description = token_data.token_description.clone();
|
||||
if let Some(description) = token_description {
|
||||
let token_syntax_color: AnsiColorIdentifier =
|
||||
description.suggestion_type.to_name().into();
|
||||
|
||||
let style_byte_offset_start = ByteOffset::from(token_data.token.span.start());
|
||||
let style_byte_offset_end = ByteOffset::from(token_data.token.span.end());
|
||||
|
||||
colors.push((
|
||||
style_byte_offset_start..style_byte_offset_end,
|
||||
token_syntax_color,
|
||||
))
|
||||
}
|
||||
}
|
||||
colors
|
||||
}
|
||||
|
||||
pub fn transform_ansi_color_to_solid_color(
|
||||
colors: &[(Range<ByteOffset>, AnsiColorIdentifier)],
|
||||
terminal_colors_normal: &AnsiColors,
|
||||
background_color: ColorU,
|
||||
) -> Vec<(Range<ByteOffset>, ColorU)> {
|
||||
colors
|
||||
.iter()
|
||||
.map(|(range, identifier)| {
|
||||
let foreground_color: ColorU =
|
||||
(*identifier).to_ansi_color(terminal_colors_normal).into();
|
||||
(
|
||||
range.clone(),
|
||||
foreground_color.on_background(background_color, MinimumAllowedContrast::Text),
|
||||
)
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Implementation for the omnibar - a floating menu for editor interactions
|
||||
//! like formatting and changing block types.
|
||||
|
||||
use itertools::Itertools;
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
use warp_editor::{
|
||||
content::text::{
|
||||
BlockType as ContentBlockType, BufferBlockStyle, BufferTextStyle, TextStyles,
|
||||
TextStylesWithMetadata,
|
||||
},
|
||||
model::RichTextEditorModel,
|
||||
render::model::RenderState,
|
||||
};
|
||||
use warpui::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole},
|
||||
elements::{
|
||||
AnchorPair, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Flex,
|
||||
MainAxisSize, MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, Point,
|
||||
PositionedElementOffsetBounds, PositioningAxis, Radius, Rect, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, SizeConstraint, TypedActionView,
|
||||
View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
menu::MenuVariant,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
view_components::{CompactDropdown, CompactDropdownEvent, CompactDropdownItem},
|
||||
};
|
||||
|
||||
use super::{
|
||||
model::{NotebooksEditorModel, RichTextEditorModelEvent},
|
||||
view::EditorViewAction,
|
||||
BlockType,
|
||||
};
|
||||
|
||||
const OMNIBAR_HEIGHT: f32 = 32.;
|
||||
const OMNIBAR_PADDING: f32 = 4.;
|
||||
|
||||
const ACTION_BUTTON_SIZE: f32 = 24.;
|
||||
|
||||
pub enum OmnibarEvent {
|
||||
OpenLinkEditor,
|
||||
}
|
||||
|
||||
/// View to render the omnibar.
|
||||
pub struct Omnibar {
|
||||
model: ModelHandle<NotebooksEditorModel>,
|
||||
|
||||
block_conversion_dropdown: ViewHandle<CompactDropdown<OmnibarAction>>,
|
||||
|
||||
bold_button_state: MouseStateHandle,
|
||||
italicize_button_state: MouseStateHandle,
|
||||
underline_button_state: MouseStateHandle,
|
||||
strikethrough_button_state: MouseStateHandle,
|
||||
link_button_state: MouseStateHandle,
|
||||
inline_code_button_state: MouseStateHandle,
|
||||
|
||||
active_text_styles: Option<TextStylesWithMetadata>,
|
||||
active_block_type: Option<ContentBlockType>,
|
||||
}
|
||||
|
||||
impl Omnibar {
|
||||
pub fn new(model: ModelHandle<NotebooksEditorModel>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let block_conversion_dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = CompactDropdown::new(MenuVariant::Fixed, ctx);
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
dropdown.set_items(
|
||||
BlockType::all()
|
||||
.map(|block_type| conversion_item(block_type, appearance))
|
||||
.collect_vec(),
|
||||
ctx,
|
||||
);
|
||||
dropdown.set_icon_size(ACTION_BUTTON_SIZE - 2. * OMNIBAR_PADDING);
|
||||
dropdown
|
||||
});
|
||||
ctx.subscribe_to_view(&block_conversion_dropdown, Self::handle_dropdown_event);
|
||||
|
||||
ctx.subscribe_to_model(&model, Self::handle_model_event);
|
||||
|
||||
Self {
|
||||
model,
|
||||
block_conversion_dropdown,
|
||||
link_button_state: Default::default(),
|
||||
bold_button_state: Default::default(),
|
||||
strikethrough_button_state: Default::default(),
|
||||
italicize_button_state: Default::default(),
|
||||
underline_button_state: Default::default(),
|
||||
inline_code_button_state: Default::default(),
|
||||
active_text_styles: None,
|
||||
active_block_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The relative positioning of the omnibar.
|
||||
///
|
||||
/// The omnibar is positioned above the current text selection, clamped to the viewport. If
|
||||
/// no portion of the text selection is visible, the omnibar is not shown.
|
||||
pub fn positioning(render_state: &RenderState) -> OffsetPositioning {
|
||||
let selection_position = render_state.saved_positions().text_selection_id();
|
||||
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&selection_position,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Middle, XAxisAnchor::Middle),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&selection_position,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(-4.),
|
||||
// TODO(ben): Decide if this should be above or below the cursor based
|
||||
// on its location within the viewport.
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Bottom),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle_style(&mut self, style: TextStyles, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.toggle_style(style, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn unset_link(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.unset_link(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn convert_block(&mut self, style: BufferBlockStyle, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.convert_block(style, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_action_button(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
action: OmnibarAction,
|
||||
active: bool,
|
||||
mouse_state: &MouseStateHandle,
|
||||
) -> Box<dyn Element> {
|
||||
let active_background = appearance.theme().surface_3().into();
|
||||
let button = icon_button(appearance, icon, active, mouse_state.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(ACTION_BUTTON_SIZE),
|
||||
height: Some(ACTION_BUTTON_SIZE),
|
||||
// Explicitly override the default icon button padding of 4px.
|
||||
// With a button size of 24px, 1px of border, and 1px of padding, each icon should
|
||||
// be 20px.
|
||||
padding: Some(Coords::uniform(1.)),
|
||||
font_color: Some(appearance.theme().nonactive_ui_text_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_active_styles(UiComponentStyles {
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.active_ui_text_color()
|
||||
// .with_opacity(100)
|
||||
.into_solid(),
|
||||
),
|
||||
background: Some(active_background),
|
||||
border_color: None,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let renderable_button = button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
|
||||
.finish();
|
||||
|
||||
Container::new(renderable_button)
|
||||
.with_margin_left(OMNIBAR_PADDING / 2.)
|
||||
.with_margin_right(OMNIBAR_PADDING / 2.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_separator(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_background(appearance.theme().disabled_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(1.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(OMNIBAR_PADDING)
|
||||
.with_margin_right(OMNIBAR_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Updates the omnibar state in response to rich text model changes.
|
||||
fn handle_model_event(
|
||||
&mut self,
|
||||
_handle: ModelHandle<NotebooksEditorModel>,
|
||||
event: &RichTextEditorModelEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let RichTextEditorModelEvent::ActiveStylesChanged {
|
||||
selection_text_styles,
|
||||
block_type,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
// The omnibar only applies to selections, so we only care about
|
||||
// the selected text styles.
|
||||
self.active_text_styles = Some(selection_text_styles.clone());
|
||||
self.active_block_type = Some(block_type.clone());
|
||||
self.reset_conversion_menu(BlockType::from(block_type), ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the conversion dropdown to the selected block type.
|
||||
fn reset_conversion_menu(&self, block_type: BlockType, ctx: &mut ViewContext<Self>) {
|
||||
let block_name = block_type.label();
|
||||
self.block_conversion_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_selected_by_name(block_name, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_dropdown_event(
|
||||
&mut self,
|
||||
_handle: ViewHandle<CompactDropdown<OmnibarAction>>,
|
||||
event: &CompactDropdownEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
CompactDropdownEvent::Close => {
|
||||
// In case the menu was closed without converting to a new type of block, reset it to
|
||||
// the original block type. If the block _was_ converted, this will be overridden
|
||||
// by the incoming model event.
|
||||
if let Some(block_type) = &self.active_block_type {
|
||||
self.reset_conversion_menu(BlockType::from(block_type), ctx);
|
||||
}
|
||||
// When the dropdown menu closes, restore focus to the parent editor view. Otherwise,
|
||||
// opening it (even if it's then dismissed) prevents typing.
|
||||
ctx.dispatch_typed_action(&EditorViewAction::Focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Omnibar {
|
||||
type Event = OmnibarEvent;
|
||||
}
|
||||
|
||||
impl View for Omnibar {
|
||||
fn ui_name() -> &'static str {
|
||||
"Omnibar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut actions = Flex::row().with_main_axis_size(MainAxisSize::Min);
|
||||
|
||||
let text_format_enabled = match self.active_block_type.as_ref() {
|
||||
Some(ContentBlockType::Item(_)) => false,
|
||||
Some(ContentBlockType::Text(block)) => block.allows_formatting(),
|
||||
None => true,
|
||||
};
|
||||
|
||||
actions.add_child(
|
||||
Container::new(ChildView::new(&self.block_conversion_dropdown).finish())
|
||||
.with_margin_left(OMNIBAR_PADDING)
|
||||
.with_margin_right(OMNIBAR_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if text_format_enabled {
|
||||
actions.add_child(self.render_separator(appearance));
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::Bold,
|
||||
OmnibarAction::BoldSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.is_normal_weight()),
|
||||
&self.bold_button_state,
|
||||
),
|
||||
);
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::Italic,
|
||||
OmnibarAction::ItalicizeSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_italic()),
|
||||
&self.italicize_button_state,
|
||||
),
|
||||
);
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::Underline,
|
||||
OmnibarAction::UnderlineSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_underlined()),
|
||||
&self.underline_button_state,
|
||||
),
|
||||
);
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::Strikethrough,
|
||||
OmnibarAction::StrikeThroughSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_strikethrough()),
|
||||
&self.strikethrough_button_state,
|
||||
),
|
||||
);
|
||||
|
||||
let link_active = self
|
||||
.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_link());
|
||||
actions.add_child(self.render_action_button(
|
||||
appearance,
|
||||
Icon::Link,
|
||||
if link_active {
|
||||
OmnibarAction::UnstyleLink
|
||||
} else {
|
||||
OmnibarAction::OpenLinkEditor
|
||||
},
|
||||
link_active,
|
||||
&self.link_button_state,
|
||||
));
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::InlineCode,
|
||||
OmnibarAction::InlineCodeSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_inline_code()),
|
||||
&self.inline_code_button_state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let bar = Container::new(
|
||||
ConstrainedBox::new(actions.finish())
|
||||
.with_height(OMNIBAR_HEIGHT - 2. * OMNIBAR_PADDING)
|
||||
.with_min_width(0.)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(OMNIBAR_PADDING)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish();
|
||||
|
||||
Compact::new(bar).finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OmnibarAction {
|
||||
/// Toggle bold styling on the selected text.
|
||||
BoldSelection,
|
||||
/// Toggle italic styling on the selected text.
|
||||
ItalicizeSelection,
|
||||
UnderlineSelection,
|
||||
StrikeThroughSelection,
|
||||
InlineCodeSelection,
|
||||
OpenLinkEditor,
|
||||
UnstyleLink,
|
||||
/// Convert the selected text to a particular kind of block.
|
||||
ConvertBlock(BufferBlockStyle),
|
||||
}
|
||||
|
||||
impl TypedActionView for Omnibar {
|
||||
type Action = OmnibarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
OmnibarAction::BoldSelection => self.toggle_style(TextStyles::default().bold(), ctx),
|
||||
OmnibarAction::ItalicizeSelection => {
|
||||
self.toggle_style(TextStyles::default().italic(), ctx)
|
||||
}
|
||||
OmnibarAction::UnderlineSelection => {
|
||||
self.toggle_style(TextStyles::default().underline(), ctx)
|
||||
}
|
||||
OmnibarAction::StrikeThroughSelection => {
|
||||
self.toggle_style(TextStyles::default().strikethrough(), ctx)
|
||||
}
|
||||
OmnibarAction::InlineCodeSelection => {
|
||||
self.toggle_style(TextStyles::default().inline_code(), ctx)
|
||||
}
|
||||
OmnibarAction::OpenLinkEditor => ctx.emit(OmnibarEvent::OpenLinkEditor),
|
||||
OmnibarAction::UnstyleLink => self.unset_link(ctx),
|
||||
OmnibarAction::ConvertBlock(style) => {
|
||||
self.convert_block(style.clone(), ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_accessibility_contents(
|
||||
&mut self,
|
||||
action: &Self::Action,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ActionAccessibilityContent {
|
||||
match action {
|
||||
OmnibarAction::BoldSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::bold()),
|
||||
OmnibarAction::ItalicizeSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::Italic),
|
||||
OmnibarAction::UnderlineSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::Underline),
|
||||
OmnibarAction::StrikeThroughSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::StrikeThrough),
|
||||
OmnibarAction::InlineCodeSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::InlineCode),
|
||||
OmnibarAction::ConvertBlock(style) => {
|
||||
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
|
||||
format!("Convert to {}", BlockType::from(style).label()),
|
||||
WarpA11yRole::UserAction,
|
||||
))
|
||||
}
|
||||
OmnibarAction::OpenLinkEditor => ActionAccessibilityContent::from_debug(),
|
||||
OmnibarAction::UnstyleLink => ActionAccessibilityContent::Custom(
|
||||
AccessibilityContent::new_without_help("Remove link", WarpA11yRole::UserAction),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a dropdown item for converting to the given block type.
|
||||
fn conversion_item(
|
||||
block_type: BlockType,
|
||||
appearance: &Appearance,
|
||||
) -> CompactDropdownItem<OmnibarAction> {
|
||||
let action = OmnibarAction::ConvertBlock(block_type.into());
|
||||
let mut item = CompactDropdownItem::new(block_type.icon(), block_type.label(), action);
|
||||
if let Some(icon_fill) = block_type.icon_color(appearance) {
|
||||
item = item.with_icon_color(icon_fill);
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// Small UI element that disregards the parent's minimum size constraint. This
|
||||
/// lets its child shrink to its content size. It's useful for offset-positioned
|
||||
/// [`Flex`] elements, which often have a minimum size constraint of their parent's
|
||||
/// size, and would otherwise expand to fill it.
|
||||
struct Compact {
|
||||
child: Box<dyn Element>,
|
||||
}
|
||||
|
||||
impl Compact {
|
||||
fn new(child: Box<dyn Element>) -> Self {
|
||||
Self { child }
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Compact {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: warpui::SizeConstraint,
|
||||
ctx: &mut warpui::LayoutContext,
|
||||
app: &warpui::AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(
|
||||
SizeConstraint {
|
||||
min: Vector2F::zero(),
|
||||
max: constraint.max,
|
||||
},
|
||||
ctx,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
origin: Vector2F,
|
||||
ctx: &mut warpui::PaintContext,
|
||||
app: &warpui::AppContext,
|
||||
) {
|
||||
self.child.paint(origin, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &warpui::event::DispatchedEvent,
|
||||
ctx: &mut warpui::EventContext,
|
||||
app: &warpui::AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app)
|
||||
}
|
||||
|
||||
fn z_index(&self) -> Option<warpui::elements::ZIndex> {
|
||||
self.child.z_index()
|
||||
}
|
||||
|
||||
fn bounds(&self) -> Option<RectF> {
|
||||
self.child.bounds()
|
||||
}
|
||||
|
||||
fn parent_data(&self) -> Option<&dyn std::any::Any> {
|
||||
self.child.parent_data()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,662 @@
|
||||
use crate::features::FeatureFlag;
|
||||
use async_channel::TryRecvError;
|
||||
use std::sync::Arc;
|
||||
use string_offset::CharOffset;
|
||||
use warp_editor::render::{
|
||||
element::RichTextAction,
|
||||
model::{HitTestBlockType, Location, RenderEvent},
|
||||
};
|
||||
use warp_util::user_input::UserInput;
|
||||
|
||||
use warpui::event::ModifiersState;
|
||||
use warpui::r#async::block_on;
|
||||
use warpui::windowing::WindowManager;
|
||||
use warpui::{platform::WindowStyle, presenter::ChildView, App, Element, Entity, View, ViewHandle};
|
||||
use warpui::{SingletonEntity, TypedActionView, WindowId};
|
||||
|
||||
use super::{EditorViewAction, RichTextEditorConfig, RichTextEditorView};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::editor::link_editor::LinkEditorAction;
|
||||
use crate::notebooks::editor::model::NotebooksEditorModel;
|
||||
use crate::notebooks::editor::rich_text_styles;
|
||||
use crate::notebooks::link::{NotebookLinks, SessionSource};
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
use crate::settings::FontSettings;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::UserWorkspaces;
|
||||
use crate::{
|
||||
cloud_object::model::persistence::CloudModel, search::files::model::FileSearchModel,
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
|
||||
/// Container for a [`RichTextEditorView`] in unit tests.
|
||||
struct TestView {
|
||||
editor: ViewHandle<RichTextEditorView>,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
ChildView::new(&self.editor).finish()
|
||||
}
|
||||
}
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
fn initialize_editor(
|
||||
app: &mut App,
|
||||
) -> (
|
||||
WindowId,
|
||||
ViewHandle<RichTextEditorView>,
|
||||
ViewHandle<TestView>,
|
||||
) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
let global_resources = GlobalResourceHandles::mock(app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources));
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
||||
app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default());
|
||||
#[cfg(feature = "local_fs")]
|
||||
app.add_singleton_model(repo_metadata::RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileSearchModel::new);
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
app.add_singleton_model(TerminalKeybindings::new);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
#[cfg(feature = "voice_input")]
|
||||
app.add_singleton_model(voice_input::VoiceInput::new);
|
||||
let team_client_mock = Arc::new(MockTeamClient::new());
|
||||
let workspace_client_mock = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
team_client_mock.clone(),
|
||||
workspace_client_mock.clone(),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let (window, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let window_id = ctx.window_id();
|
||||
let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
|
||||
let editor_model = ctx.add_model(|ctx| {
|
||||
let styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(ctx));
|
||||
NotebooksEditorModel::new(styles, window_id, ctx)
|
||||
});
|
||||
let editor = ctx.add_typed_action_view(|ctx| {
|
||||
RichTextEditorView::new(
|
||||
String::new(),
|
||||
editor_model,
|
||||
links,
|
||||
RichTextEditorConfig::default(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
TestView { editor }
|
||||
});
|
||||
|
||||
let editor_view = app.read(|ctx| test_view.as_ref(ctx).editor.clone());
|
||||
(window, editor_view, test_view)
|
||||
}
|
||||
|
||||
async fn reset_editor_with_markdown(
|
||||
app: &mut App,
|
||||
editor_view: &ViewHandle<RichTextEditorView>,
|
||||
markdown: &str,
|
||||
) {
|
||||
editor_view.update(app, |editor, ctx| {
|
||||
editor.reset_with_markdown(markdown, ctx);
|
||||
editor.set_interaction_state(InteractionState::Editable, ctx);
|
||||
});
|
||||
let render_state = editor_view.read(app, |editor, ctx| {
|
||||
editor.model.as_ref(ctx).render_state().clone()
|
||||
});
|
||||
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
|
||||
.await;
|
||||
}
|
||||
|
||||
fn rendered_mermaid_block_range(
|
||||
editor: &RichTextEditorView,
|
||||
ctx: &warpui::AppContext,
|
||||
) -> Option<std::ops::Range<CharOffset>> {
|
||||
let render_state = editor.model.as_ref(ctx).render_state().clone();
|
||||
let render_state = render_state.as_ref(ctx);
|
||||
let content = render_state.content();
|
||||
let mut block_start = CharOffset::zero();
|
||||
|
||||
for block in content.block_items() {
|
||||
let block_end = block_start + block.content_length();
|
||||
if matches!(
|
||||
block,
|
||||
warp_editor::render::model::BlockItem::MermaidDiagram { .. }
|
||||
) {
|
||||
return Some(block_start..block_end);
|
||||
}
|
||||
block_start = block_end;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focus() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window, editor_view, test_view) = initialize_editor(&mut app);
|
||||
|
||||
// The editor isn't focused, so it should ignore the typed characters.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::UserTyped(UserInput::new("abc")), ctx);
|
||||
});
|
||||
editor_view.read(&app, |editor, ctx| assert!(editor.markdown(ctx).is_empty()));
|
||||
|
||||
// Once the editor gains focus, it should start dispatching key events.
|
||||
editor_view.update(&mut app, |_, ctx| {
|
||||
ctx.focus_self();
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::UserTyped(UserInput::new("abc")), ctx);
|
||||
});
|
||||
editor_view.read(&app, |editor, ctx| assert_eq!(&editor.markdown(ctx), "abc"));
|
||||
|
||||
// Focus the root view to ensure that the editor is not focused at the framework level.
|
||||
test_view.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
assert_ne!(app.focused_view_id(window), Some(editor_view.id()));
|
||||
|
||||
// Clicking into the editor should restore focus.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_start(CharOffset::from(2), false, ctx);
|
||||
});
|
||||
assert_eq!(app.focused_view_id(window), Some(editor_view.id()));
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_focus() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, editor_view, _) = initialize_editor(&mut app);
|
||||
|
||||
// Initially, the editor is not focused.
|
||||
editor_view.read(&app, |editor, ctx| assert!(!editor.is_focused(ctx)));
|
||||
|
||||
// If the editor is focused, but not the window, it's still not considered focused.
|
||||
editor_view.update(&mut app, |editor, ctx| editor.focus(ctx));
|
||||
editor_view.read(&app, |editor, ctx| assert!(!editor.is_focused(ctx)));
|
||||
|
||||
// Once the window is focused, we treat the editor as focused too.
|
||||
WindowManager::handle(&app).update(&mut app, |windowing_state, _| {
|
||||
windowing_state.overwrite_for_test(windowing_state.stage(), Some(window_id));
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| assert!(editor.is_focused(ctx)));
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_appearance_changes() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
|
||||
let render_model = editor_view.read(&app, |editor, ctx| {
|
||||
editor.model.as_ref(ctx).render_state().clone()
|
||||
});
|
||||
|
||||
// Subscribe to layout updates from the render model to verify edits.
|
||||
let layouts = {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&render_model, move |_, event, _| {
|
||||
if let RenderEvent::LayoutUpdated = event {
|
||||
block_on(tx.send(*event)).unwrap();
|
||||
}
|
||||
})
|
||||
});
|
||||
rx
|
||||
};
|
||||
|
||||
// Wait for initial layout.
|
||||
assert!(layouts.recv().await.is_ok());
|
||||
|
||||
// First, focus the editor so it is editable.
|
||||
editor_view.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.user_typed("ABC", ctx);
|
||||
});
|
||||
|
||||
// Wait for the typed text to lay out.
|
||||
assert!(layouts.recv().await.is_ok());
|
||||
|
||||
// Simulate an appearance change.
|
||||
Appearance::handle(&app).update(&mut app, |appearance, ctx| {
|
||||
appearance.set_monospace_font_family(warpui::fonts::FamilyId(123), ctx);
|
||||
ctx.notify()
|
||||
});
|
||||
|
||||
// The appearance change should cause a re-layout.
|
||||
assert!(layouts.recv().await.is_ok());
|
||||
|
||||
render_model.update(&mut app, |model, _| {
|
||||
// The render model's style should be updated.
|
||||
assert_eq!(
|
||||
model.styles().code_text.font_family,
|
||||
warpui::fonts::FamilyId(123)
|
||||
);
|
||||
});
|
||||
|
||||
assert_eq!(layouts.try_recv().unwrap_err(), TryRecvError::Empty);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_omnibar_is_hidden_for_rendered_mermaid_selection() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
editor.selection_start(mermaid_block_range.start, false, ctx);
|
||||
editor.selection_update(mermaid_block_range.end, ctx);
|
||||
editor.selection_end(ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
assert!(!editor.should_show_omnibar(ctx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shift_click_on_rendered_mermaid_dispatches_selection_update_to_block_boundary() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_start(CharOffset::from(2), false, ctx);
|
||||
editor.selection_end(ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
let mermaid_block_start = mermaid_block_range.start;
|
||||
let mermaid_block_end = mermaid_block_range.end;
|
||||
|
||||
let action = <EditorViewAction as RichTextAction<RichTextEditorView>>::left_mouse_down(
|
||||
Location::Block {
|
||||
start_offset: mermaid_block_start,
|
||||
end_offset: mermaid_block_end,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
},
|
||||
ModifiersState {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
1,
|
||||
false,
|
||||
&editor_view.downgrade(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(EditorViewAction::SelectionUpdate(mermaid_block_end))
|
||||
);
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
let mermaid_block_end = rendered_mermaid_block_range(editor, ctx)
|
||||
.expect("Expected rendered Mermaid block")
|
||||
.end;
|
||||
editor.selection_start(mermaid_block_end + 2, false, ctx);
|
||||
editor.selection_end(ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
let mermaid_block_start = mermaid_block_range.start;
|
||||
let mermaid_block_end = mermaid_block_range.end;
|
||||
|
||||
let action = <EditorViewAction as RichTextAction<RichTextEditorView>>::left_mouse_down(
|
||||
Location::Block {
|
||||
start_offset: mermaid_block_start,
|
||||
end_offset: mermaid_block_end,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
},
|
||||
ModifiersState {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
1,
|
||||
false,
|
||||
&editor_view.downgrade(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(EditorViewAction::SelectionUpdate(mermaid_block_start))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drag_on_rendered_mermaid_dispatches_selection_update_to_block_boundary() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_start(CharOffset::from(2), false, ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
let mermaid_block_start = mermaid_block_range.start;
|
||||
let mermaid_block_end = mermaid_block_range.end;
|
||||
|
||||
let action =
|
||||
<EditorViewAction as RichTextAction<RichTextEditorView>>::left_mouse_dragged(
|
||||
Location::Block {
|
||||
start_offset: mermaid_block_start,
|
||||
end_offset: mermaid_block_end,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
&editor_view.downgrade(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(EditorViewAction::SelectionUpdate(mermaid_block_end))
|
||||
);
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_end(ctx);
|
||||
let mermaid_block_end = rendered_mermaid_block_range(editor, ctx)
|
||||
.expect("Expected rendered Mermaid block")
|
||||
.end;
|
||||
editor.selection_start(mermaid_block_end + 2, false, ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
let mermaid_block_start = mermaid_block_range.start;
|
||||
let mermaid_block_end = mermaid_block_range.end;
|
||||
|
||||
let action =
|
||||
<EditorViewAction as RichTextAction<RichTextEditorView>>::left_mouse_dragged(
|
||||
Location::Block {
|
||||
start_offset: mermaid_block_start,
|
||||
end_offset: mermaid_block_end,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
&editor_view.downgrade(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(EditorViewAction::SelectionUpdate(mermaid_block_start))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_editing() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
// First, focus the editor so it is editable.
|
||||
editor_view.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
|
||||
// Select some text and open the link editor. This must be split across several updates so
|
||||
// that model changes don't close the link editor.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.user_typed("Some text", ctx);
|
||||
editor.handle_action(&EditorViewAction::SelectBackwardsByWord, ctx);
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::CreateOrEditLink, ctx);
|
||||
});
|
||||
|
||||
// Populate the link editor to create a hyperlink.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
assert!(editor.link_editor_open);
|
||||
let link_editor = editor.link_editor.as_ref(ctx);
|
||||
assert!(link_editor.url_editor().is_focused(ctx));
|
||||
assert_eq!(
|
||||
link_editor.tag_editor().as_ref(ctx).buffer_text(ctx),
|
||||
"text"
|
||||
);
|
||||
|
||||
link_editor
|
||||
.url_editor()
|
||||
.clone()
|
||||
.update(ctx, |url_editor, ctx| {
|
||||
url_editor.user_insert("https://warp.dev", ctx);
|
||||
});
|
||||
|
||||
editor.link_editor.update(ctx, |link_editor, ctx| {
|
||||
link_editor.handle_action(&LinkEditorAction::ApplyLink, ctx)
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure that the link was created.
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
assert_eq!(
|
||||
editor.model.as_ref(ctx).debug_buffer(ctx),
|
||||
"<text>Some <a_https://warp.dev>text<a>"
|
||||
);
|
||||
});
|
||||
|
||||
// Create a separate link after the first one, with no initial text selection.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::MoveToLineEnd, ctx);
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::CreateOrEditLink, ctx);
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
assert!(editor.link_editor_open);
|
||||
let tag_editor = editor.link_editor.as_ref(ctx).tag_editor().clone();
|
||||
let url_editor = editor.link_editor.as_ref(ctx).url_editor().clone();
|
||||
|
||||
url_editor.update(ctx, |url_editor, ctx| {
|
||||
url_editor.user_insert("https://example.com", ctx);
|
||||
});
|
||||
tag_editor.update(ctx, |tag_editor, ctx| {
|
||||
assert!(tag_editor.is_empty(ctx));
|
||||
tag_editor.user_insert("new link", ctx)
|
||||
});
|
||||
|
||||
editor.link_editor.update(ctx, |link_editor, ctx| {
|
||||
link_editor.handle_action(&LinkEditorAction::ApplyLink, ctx)
|
||||
});
|
||||
});
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
assert_eq!(
|
||||
editor.model.as_ref(ctx).debug_buffer(ctx),
|
||||
"<text>Some <a_https://warp.dev>text<a><a_https://example.com>new link<a>"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_command_from_text_selection() {
|
||||
// This tests that, starting from a text selection, we can still run a command.
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
let (tx, has_layout) = futures::channel::oneshot::channel();
|
||||
app.update(|ctx| {
|
||||
let mut tx = Some(tx);
|
||||
let render_state = editor_view
|
||||
.as_ref(ctx)
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.render_state()
|
||||
.clone();
|
||||
ctx.subscribe_to_model(&render_state, move |_, event, _ctx| {
|
||||
if let RenderEvent::LayoutUpdated = event {
|
||||
if let Some(tx) = tx.take() {
|
||||
tx.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.reset_with_markdown("Text\n```\necho hi\n```\n```\necho hello\n```", ctx);
|
||||
});
|
||||
has_layout.await.expect("Model was not laid out");
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
// Simulate cmd-enter in a non-text block, which should be a no-op.
|
||||
editor.selection_start(3.into(), false, ctx);
|
||||
editor.run_selected_commands(ctx);
|
||||
assert!(!editor.model.as_ref(ctx).has_command_selection(ctx));
|
||||
|
||||
// If the cursor is in a command block, cmd-enter should auto-select it.
|
||||
editor.selection_start(8.into(), false, ctx);
|
||||
editor.run_selected_commands(ctx);
|
||||
let selected_command = editor
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.selected_command_workflow(ctx)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected_command
|
||||
.workflow
|
||||
.as_workflow()
|
||||
.command()
|
||||
.expect("Workflow is Command Workflow"),
|
||||
"echo hi"
|
||||
);
|
||||
|
||||
// If the text cursor was in one command block, but another is selected, cmd-enter
|
||||
// should run the selected command.
|
||||
editor.command_down(ctx);
|
||||
editor.run_selected_commands(ctx);
|
||||
let selected_command = editor
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.selected_command_workflow(ctx)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected_command
|
||||
.workflow
|
||||
.as_workflow()
|
||||
.command()
|
||||
.expect("Workflow is Command Workflow"),
|
||||
"echo hello"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_editing_disabled_for_multiselect() {
|
||||
// Ensure that if multiple selections are made, that the link editor is not opened.
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
// First, focus the editor so it is editable.
|
||||
editor_view.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
|
||||
// Select some text and open the link editor. This must be split across several updates so
|
||||
// that model changes don't close the link editor.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.user_typed("Some text", ctx);
|
||||
editor.handle_action(&EditorViewAction::SelectBackwardsByWord, ctx);
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
assert_eq!(editor.model().as_ref(ctx).selected_text(ctx), "text");
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::CreateOrEditLink, ctx);
|
||||
});
|
||||
|
||||
// Populate the link editor to create a hyperlink.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
assert!(editor.link_editor_open);
|
||||
let link_editor = editor.link_editor.as_ref(ctx);
|
||||
assert!(link_editor.url_editor().is_focused(ctx));
|
||||
assert_eq!(
|
||||
link_editor.tag_editor().as_ref(ctx).buffer_text(ctx),
|
||||
"text"
|
||||
);
|
||||
|
||||
link_editor
|
||||
.url_editor()
|
||||
.clone()
|
||||
.update(ctx, |url_editor, ctx| {
|
||||
url_editor.user_insert("https://warp.dev", ctx);
|
||||
});
|
||||
|
||||
editor.link_editor.update(ctx, |link_editor, ctx| {
|
||||
link_editor.handle_action(&LinkEditorAction::ApplyLink, ctx)
|
||||
});
|
||||
});
|
||||
|
||||
// Add another selection.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(
|
||||
&EditorViewAction::SelectionStart {
|
||||
offset: 1.into(),
|
||||
multiselect: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Try to open the link editor.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::CreateOrEditLink, ctx);
|
||||
});
|
||||
|
||||
// Ensure that the link editor was not opened.
|
||||
editor_view.read(&app, |editor, _ctx| {
|
||||
assert!(!editor.link_editor_open);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user