Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -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);
}
}
+647
View File
@@ -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)
}
}
+381
View File
@@ -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())
}
}
+639
View File
@@ -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;
}
+66
View File
@@ -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())
}
+296
View File
@@ -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);
}
}
}
+365
View File
@@ -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 &current_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()
}
+546
View File
@@ -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
+662
View File
@@ -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);
});
});
}