Allow editor blocks to replace ranges of text (#19531)

Max Brunsfeld , Antonio Scandurra , Richard Feldman , Marshall Bowers , and Nathan Sobo created

This PR adds the ability for editor blocks to replace lines of text, but
does not yet use that feature anywhere. We'll update assistant patches
to use replace blocks on another branch:
https://github.com/zed-industries/zed/tree/assistant-patch-replace-blocks

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Richard Feldman <richard@zed.dev>
Co-authored-by: Marshall Bowers <marshall@zed.dev>
Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

crates/assistant/src/assistant_panel.rs    |  27 
crates/assistant/src/inline_assistant.rs   |  11 
crates/diagnostics/src/diagnostics.rs      |  28 
crates/editor/src/display_map.rs           |  19 
crates/editor/src/display_map/block_map.rs | 729 +++++++++++++++--------
crates/editor/src/display_map/char_map.rs  |  33 +
crates/editor/src/display_map/fold_map.rs  |  43 +
crates/editor/src/display_map/wrap_map.rs  | 126 +++
crates/editor/src/editor.rs                |   8 
crates/editor/src/editor_tests.rs          |   3 
crates/editor/src/element.rs               |   7 
crates/editor/src/hunk_diff.rs             |   8 
crates/repl/src/session.rs                 |   5 
13 files changed, 727 insertions(+), 320 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -26,8 +26,8 @@ use collections::{BTreeSet, HashMap, HashSet};
 use editor::{
     actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt},
     display_map::{
-        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Crease,
-        CreaseMetadata, CustomBlockId, FoldId, RenderBlock, ToDisplayPoint,
+        BlockContext, BlockId, BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata,
+        CustomBlockId, FoldId, RenderBlock, ToDisplayPoint,
     },
     scroll::{Autoscroll, AutoscrollStrategy},
     Anchor, Editor, EditorEvent, ProposedChangeLocation, ProposedChangesEditor, RowExt,
@@ -2009,13 +2009,12 @@ impl ContextEditor {
                             })
                             .map(|(command, error_message)| BlockProperties {
                                 style: BlockStyle::Fixed,
-                                position: Anchor {
+                                height: 1,
+                                placement: BlockPlacement::Below(Anchor {
                                     buffer_id: Some(buffer_id),
                                     excerpt_id,
                                     text_anchor: command.source_range.start,
-                                },
-                                height: 1,
-                                disposition: BlockDisposition::Below,
+                                }),
                                 render: slash_command_error_block_renderer(error_message),
                                 priority: 0,
                             }),
@@ -2242,11 +2241,10 @@ impl ContextEditor {
                 } else {
                     let block_ids = editor.insert_blocks(
                         [BlockProperties {
-                            position: patch_start,
                             height: path_count as u32 + 1,
                             style: BlockStyle::Flex,
                             render: render_block,
-                            disposition: BlockDisposition::Below,
+                            placement: BlockPlacement::Below(patch_start),
                             priority: 0,
                         }],
                         None,
@@ -2731,12 +2729,13 @@ impl ContextEditor {
                 })
             };
             let create_block_properties = |message: &Message| BlockProperties {
-                position: buffer
-                    .anchor_in_excerpt(excerpt_id, message.anchor_range.start)
-                    .unwrap(),
                 height: 2,
                 style: BlockStyle::Sticky,
-                disposition: BlockDisposition::Above,
+                placement: BlockPlacement::Above(
+                    buffer
+                        .anchor_in_excerpt(excerpt_id, message.anchor_range.start)
+                        .unwrap(),
+                ),
                 priority: usize::MAX,
                 render: render_block(MessageMetadata::from(message)),
             };
@@ -3372,7 +3371,7 @@ impl ContextEditor {
                     let anchor = buffer.anchor_in_excerpt(excerpt_id, anchor).unwrap();
                     let image = render_image.clone();
                     anchor.is_valid(&buffer).then(|| BlockProperties {
-                        position: anchor,
+                        placement: BlockPlacement::Above(anchor),
                         height: MAX_HEIGHT_IN_LINES,
                         style: BlockStyle::Sticky,
                         render: Box::new(move |cx| {
@@ -3393,8 +3392,6 @@ impl ContextEditor {
                                 )
                                 .into_any_element()
                         }),
-
-                        disposition: BlockDisposition::Above,
                         priority: 0,
                     })
                 })

crates/assistant/src/inline_assistant.rs 🔗

@@ -9,7 +9,7 @@ use collections::{hash_map, HashMap, HashSet, VecDeque};
 use editor::{
     actions::{MoveDown, MoveUp, SelectAll},
     display_map::{
-        BlockContext, BlockDisposition, BlockProperties, BlockStyle, CustomBlockId, RenderBlock,
+        BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, RenderBlock,
         ToDisplayPoint,
     },
     Anchor, AnchorRangeExt, CodeActionProvider, Editor, EditorElement, EditorEvent, EditorMode,
@@ -446,15 +446,14 @@ impl InlineAssistant {
         let assist_blocks = vec![
             BlockProperties {
                 style: BlockStyle::Sticky,
-                position: range.start,
+                placement: BlockPlacement::Above(range.start),
                 height: prompt_editor_height,
                 render: build_assist_editor_renderer(prompt_editor),
-                disposition: BlockDisposition::Above,
                 priority: 0,
             },
             BlockProperties {
                 style: BlockStyle::Sticky,
-                position: range.end,
+                placement: BlockPlacement::Below(range.end),
                 height: 0,
                 render: Box::new(|cx| {
                     v_flex()
@@ -464,7 +463,6 @@ impl InlineAssistant {
                         .border_color(cx.theme().status().info_border)
                         .into_any_element()
                 }),
-                disposition: BlockDisposition::Below,
                 priority: 0,
             },
         ];
@@ -1179,7 +1177,7 @@ impl InlineAssistant {
                 let height =
                     deleted_lines_editor.update(cx, |editor, cx| editor.max_point(cx).row().0 + 1);
                 new_blocks.push(BlockProperties {
-                    position: new_row,
+                    placement: BlockPlacement::Above(new_row),
                     height,
                     style: BlockStyle::Flex,
                     render: Box::new(move |cx| {
@@ -1191,7 +1189,6 @@ impl InlineAssistant {
                             .child(deleted_lines_editor.clone())
                             .into_any_element()
                     }),
-                    disposition: BlockDisposition::Above,
                     priority: 0,
                 });
             }

crates/diagnostics/src/diagnostics.rs 🔗

@@ -9,7 +9,7 @@ use anyhow::Result;
 use collections::{BTreeSet, HashSet};
 use editor::{
     diagnostic_block_renderer,
-    display_map::{BlockDisposition, BlockProperties, BlockStyle, CustomBlockId, RenderBlock},
+    display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, RenderBlock},
     highlight_diagnostic_message,
     scroll::Autoscroll,
     Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset,
@@ -439,11 +439,10 @@ impl ProjectDiagnosticsEditor {
                                     primary.message.split('\n').next().unwrap().to_string();
                                 group_state.block_count += 1;
                                 blocks_to_add.push(BlockProperties {
-                                    position: header_position,
+                                    placement: BlockPlacement::Above(header_position),
                                     height: 2,
                                     style: BlockStyle::Sticky,
                                     render: diagnostic_header_renderer(primary),
-                                    disposition: BlockDisposition::Above,
                                     priority: 0,
                                 });
                             }
@@ -459,13 +458,15 @@ impl ProjectDiagnosticsEditor {
                                 if !diagnostic.message.is_empty() {
                                     group_state.block_count += 1;
                                     blocks_to_add.push(BlockProperties {
-                                        position: (excerpt_id, entry.range.start),
+                                        placement: BlockPlacement::Below((
+                                            excerpt_id,
+                                            entry.range.start,
+                                        )),
                                         height: diagnostic.message.matches('\n').count() as u32 + 1,
                                         style: BlockStyle::Fixed,
                                         render: diagnostic_block_renderer(
                                             diagnostic, None, true, true,
                                         ),
-                                        disposition: BlockDisposition::Below,
                                         priority: 0,
                                     });
                                 }
@@ -498,13 +499,24 @@ impl ProjectDiagnosticsEditor {
             editor.remove_blocks(blocks_to_remove, None, cx);
             let block_ids = editor.insert_blocks(
                 blocks_to_add.into_iter().flat_map(|block| {
-                    let (excerpt_id, text_anchor) = block.position;
+                    let placement = match block.placement {
+                        BlockPlacement::Above((excerpt_id, text_anchor)) => BlockPlacement::Above(
+                            excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor)?,
+                        ),
+                        BlockPlacement::Below((excerpt_id, text_anchor)) => BlockPlacement::Below(
+                            excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor)?,
+                        ),
+                        BlockPlacement::Replace(_) => {
+                            unreachable!(
+                                "no Replace block should have been pushed to blocks_to_add"
+                            )
+                        }
+                    };
                     Some(BlockProperties {
-                        position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor)?,
+                        placement,
                         height: block.height,
                         style: block.style,
                         render: block.render,
-                        disposition: block.disposition,
                         priority: 0,
                     })
                 }),

crates/editor/src/display_map.rs 🔗

@@ -29,8 +29,8 @@ use crate::{
     hover_links::InlayHighlight, movement::TextLayoutDetails, EditorStyle, InlayId, RowExt,
 };
 pub use block_map::{
-    Block, BlockBufferRows, BlockChunks as DisplayChunks, BlockContext, BlockDisposition, BlockId,
-    BlockMap, BlockPoint, BlockProperties, BlockStyle, CustomBlockId, RenderBlock,
+    Block, BlockBufferRows, BlockChunks as DisplayChunks, BlockContext, BlockId, BlockMap,
+    BlockPlacement, BlockPoint, BlockProperties, BlockStyle, CustomBlockId, RenderBlock,
 };
 use block_map::{BlockRow, BlockSnapshot};
 use char_map::{CharMap, CharSnapshot};
@@ -1180,6 +1180,7 @@ impl ToDisplayPoint for Anchor {
 pub mod tests {
     use super::*;
     use crate::{movement, test::marked_display_snapshot};
+    use block_map::BlockPlacement;
     use gpui::{div, font, observe, px, AppContext, BorrowAppContext, Context, Element, Hsla};
     use language::{
         language_settings::{AllLanguageSettings, AllLanguageSettingsContent},
@@ -1293,24 +1294,22 @@ pub mod tests {
                                             Bias::Left,
                                         ));
 
-                                    let disposition = if rng.gen() {
-                                        BlockDisposition::Above
+                                    let placement = if rng.gen() {
+                                        BlockPlacement::Above(position)
                                     } else {
-                                        BlockDisposition::Below
+                                        BlockPlacement::Below(position)
                                     };
                                     let height = rng.gen_range(1..5);
                                     log::info!(
-                                        "inserting block {:?} {:?} with height {}",
-                                        disposition,
-                                        position.to_point(&buffer),
+                                        "inserting block {:?} with height {}",
+                                        placement.as_ref().map(|p| p.to_point(&buffer)),
                                         height
                                     );
                                     let priority = rng.gen_range(1..100);
                                     BlockProperties {
+                                        placement,
                                         style: BlockStyle::Fixed,
-                                        position,
                                         height,
-                                        disposition,
                                         render: Box::new(|_| div().into_any()),
                                         priority,
                                     }

crates/editor/src/display_map/block_map.rs 🔗

@@ -6,7 +6,9 @@ use crate::{EditorStyle, GutterDimensions};
 use collections::{Bound, HashMap, HashSet};
 use gpui::{AnyElement, EntityId, Pixels, WindowContext};
 use language::{Chunk, Patch, Point};
-use multi_buffer::{Anchor, ExcerptId, ExcerptInfo, MultiBufferRow, ToPoint as _};
+use multi_buffer::{
+    Anchor, ExcerptId, ExcerptInfo, MultiBufferRow, MultiBufferSnapshot, ToPoint as _,
+};
 use parking_lot::Mutex;
 use std::{
     cell::RefCell,
@@ -18,7 +20,7 @@ use std::{
         Arc,
     },
 };
-use sum_tree::{Bias, SumTree, TreeMap};
+use sum_tree::{Bias, SumTree, Summary, TreeMap};
 use text::Edit;
 use ui::ElementId;
 
@@ -77,32 +79,173 @@ struct WrapRow(u32);
 
 pub type RenderBlock = Box<dyn Send + FnMut(&mut BlockContext) -> AnyElement>;
 
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum BlockPlacement<T> {
+    Above(T),
+    Below(T),
+    Replace(Range<T>),
+}
+
+impl<T> BlockPlacement<T> {
+    fn start(&self) -> &T {
+        match self {
+            BlockPlacement::Above(position) => position,
+            BlockPlacement::Below(position) => position,
+            BlockPlacement::Replace(range) => &range.start,
+        }
+    }
+
+    fn end(&self) -> &T {
+        match self {
+            BlockPlacement::Above(position) => position,
+            BlockPlacement::Below(position) => position,
+            BlockPlacement::Replace(range) => &range.end,
+        }
+    }
+
+    pub fn as_ref(&self) -> BlockPlacement<&T> {
+        match self {
+            BlockPlacement::Above(position) => BlockPlacement::Above(position),
+            BlockPlacement::Below(position) => BlockPlacement::Below(position),
+            BlockPlacement::Replace(range) => BlockPlacement::Replace(&range.start..&range.end),
+        }
+    }
+
+    pub fn map<R>(self, mut f: impl FnMut(T) -> R) -> BlockPlacement<R> {
+        match self {
+            BlockPlacement::Above(position) => BlockPlacement::Above(f(position)),
+            BlockPlacement::Below(position) => BlockPlacement::Below(f(position)),
+            BlockPlacement::Replace(range) => BlockPlacement::Replace(f(range.start)..f(range.end)),
+        }
+    }
+}
+
+impl BlockPlacement<Anchor> {
+    fn cmp(&self, other: &Self, buffer: &MultiBufferSnapshot) -> Ordering {
+        match (self, other) {
+            (BlockPlacement::Above(anchor_a), BlockPlacement::Above(anchor_b))
+            | (BlockPlacement::Below(anchor_a), BlockPlacement::Below(anchor_b)) => {
+                anchor_a.cmp(anchor_b, buffer)
+            }
+            (BlockPlacement::Above(anchor_a), BlockPlacement::Below(anchor_b)) => {
+                anchor_a.cmp(anchor_b, buffer).then(Ordering::Less)
+            }
+            (BlockPlacement::Below(anchor_a), BlockPlacement::Above(anchor_b)) => {
+                anchor_a.cmp(anchor_b, buffer).then(Ordering::Greater)
+            }
+            (BlockPlacement::Above(anchor), BlockPlacement::Replace(range)) => {
+                anchor.cmp(&range.start, buffer).then(Ordering::Less)
+            }
+            (BlockPlacement::Replace(range), BlockPlacement::Above(anchor)) => {
+                range.start.cmp(anchor, buffer).then(Ordering::Greater)
+            }
+            (BlockPlacement::Below(anchor), BlockPlacement::Replace(range)) => {
+                anchor.cmp(&range.start, buffer).then(Ordering::Greater)
+            }
+            (BlockPlacement::Replace(range), BlockPlacement::Below(anchor)) => {
+                range.start.cmp(anchor, buffer).then(Ordering::Less)
+            }
+            (BlockPlacement::Replace(range_a), BlockPlacement::Replace(range_b)) => range_a
+                .start
+                .cmp(&range_b.start, buffer)
+                .then_with(|| range_b.end.cmp(&range_a.end, buffer)),
+        }
+    }
+
+    fn to_wrap_row(&self, wrap_snapshot: &WrapSnapshot) -> Option<BlockPlacement<WrapRow>> {
+        let buffer_snapshot = wrap_snapshot.buffer_snapshot();
+        match self {
+            BlockPlacement::Above(position) => {
+                let mut position = position.to_point(buffer_snapshot);
+                position.column = 0;
+                let wrap_row = WrapRow(wrap_snapshot.make_wrap_point(position, Bias::Left).row());
+                Some(BlockPlacement::Above(wrap_row))
+            }
+            BlockPlacement::Below(position) => {
+                let mut position = position.to_point(buffer_snapshot);
+                position.column = buffer_snapshot.line_len(MultiBufferRow(position.row));
+                let wrap_row = WrapRow(wrap_snapshot.make_wrap_point(position, Bias::Left).row());
+                Some(BlockPlacement::Below(wrap_row))
+            }
+            BlockPlacement::Replace(range) => {
+                let mut start = range.start.to_point(buffer_snapshot);
+                let mut end = range.end.to_point(buffer_snapshot);
+                if start == end {
+                    None
+                } else {
+                    start.column = 0;
+                    let start_wrap_row =
+                        WrapRow(wrap_snapshot.make_wrap_point(start, Bias::Left).row());
+                    end.column = buffer_snapshot.line_len(MultiBufferRow(end.row));
+                    let end_wrap_row =
+                        WrapRow(wrap_snapshot.make_wrap_point(end, Bias::Left).row());
+                    Some(BlockPlacement::Replace(start_wrap_row..end_wrap_row))
+                }
+            }
+        }
+    }
+}
+
+impl Ord for BlockPlacement<WrapRow> {
+    fn cmp(&self, other: &Self) -> Ordering {
+        match (self, other) {
+            (BlockPlacement::Above(row_a), BlockPlacement::Above(row_b))
+            | (BlockPlacement::Below(row_a), BlockPlacement::Below(row_b)) => row_a.cmp(row_b),
+            (BlockPlacement::Above(row_a), BlockPlacement::Below(row_b)) => {
+                row_a.cmp(row_b).then(Ordering::Less)
+            }
+            (BlockPlacement::Below(row_a), BlockPlacement::Above(row_b)) => {
+                row_a.cmp(row_b).then(Ordering::Greater)
+            }
+            (BlockPlacement::Above(row), BlockPlacement::Replace(range)) => {
+                row.cmp(&range.start).then(Ordering::Less)
+            }
+            (BlockPlacement::Replace(range), BlockPlacement::Above(row)) => {
+                range.start.cmp(row).then(Ordering::Greater)
+            }
+            (BlockPlacement::Below(row), BlockPlacement::Replace(range)) => {
+                row.cmp(&range.start).then(Ordering::Greater)
+            }
+            (BlockPlacement::Replace(range), BlockPlacement::Below(row)) => {
+                range.start.cmp(row).then(Ordering::Less)
+            }
+            (BlockPlacement::Replace(range_a), BlockPlacement::Replace(range_b)) => range_a
+                .start
+                .cmp(&range_b.start)
+                .then_with(|| range_b.end.cmp(&range_a.end)),
+        }
+    }
+}
+
+impl PartialOrd for BlockPlacement<WrapRow> {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
 pub struct CustomBlock {
     id: CustomBlockId,
-    position: Anchor,
+    placement: BlockPlacement<Anchor>,
     height: u32,
     style: BlockStyle,
     render: Arc<Mutex<RenderBlock>>,
-    disposition: BlockDisposition,
     priority: usize,
 }
 
 pub struct BlockProperties<P> {
-    pub position: P,
+    pub placement: BlockPlacement<P>,
     pub height: u32,
     pub style: BlockStyle,
     pub render: RenderBlock,
-    pub disposition: BlockDisposition,
     pub priority: usize,
 }
 
 impl<P: Debug> Debug for BlockProperties<P> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("BlockProperties")
-            .field("position", &self.position)
+            .field("placement", &self.placement)
             .field("height", &self.height)
             .field("style", &self.style)
-            .field("disposition", &self.disposition)
             .finish()
     }
 }
@@ -125,10 +268,10 @@ pub struct BlockContext<'a, 'b> {
     pub editor_style: &'b EditorStyle,
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash)]
 pub enum BlockId {
-    Custom(CustomBlockId),
     ExcerptBoundary(Option<ExcerptId>),
+    Custom(CustomBlockId),
 }
 
 impl From<BlockId> for ElementId {
@@ -152,30 +295,12 @@ impl std::fmt::Display for BlockId {
     }
 }
 
-/// Whether the block should be considered above or below the anchor line
-#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
-pub enum BlockDisposition {
-    Above,
-    Below,
-}
-
 #[derive(Clone, Debug)]
 struct Transform {
     summary: TransformSummary,
     block: Option<Block>,
 }
 
-pub(crate) enum BlockType {
-    Custom(CustomBlockId),
-    ExcerptBoundary,
-}
-
-pub(crate) trait BlockLike {
-    fn block_type(&self) -> BlockType;
-    fn disposition(&self) -> BlockDisposition;
-    fn priority(&self) -> usize;
-}
-
 #[allow(clippy::large_enum_variant)]
 #[derive(Clone)]
 pub enum Block {
@@ -189,26 +314,6 @@ pub enum Block {
     },
 }
 
-impl BlockLike for Block {
-    fn block_type(&self) -> BlockType {
-        match self {
-            Block::Custom(block) => BlockType::Custom(block.id),
-            Block::ExcerptBoundary { .. } => BlockType::ExcerptBoundary,
-        }
-    }
-
-    fn disposition(&self) -> BlockDisposition {
-        self.disposition()
-    }
-
-    fn priority(&self) -> usize {
-        match self {
-            Block::Custom(block) => block.priority,
-            Block::ExcerptBoundary { .. } => usize::MAX,
-        }
-    }
-}
-
 impl Block {
     pub fn id(&self) -> BlockId {
         match self {
@@ -219,19 +324,6 @@ impl Block {
         }
     }
 
-    fn disposition(&self) -> BlockDisposition {
-        match self {
-            Block::Custom(block) => block.disposition,
-            Block::ExcerptBoundary { next_excerpt, .. } => {
-                if next_excerpt.is_some() {
-                    BlockDisposition::Above
-                } else {
-                    BlockDisposition::Below
-                }
-            }
-        }
-    }
-
     pub fn height(&self) -> u32 {
         match self {
             Block::Custom(block) => block.height,
@@ -245,6 +337,20 @@ impl Block {
             Block::ExcerptBoundary { .. } => BlockStyle::Sticky,
         }
     }
+
+    fn place_above(&self) -> bool {
+        match self {
+            Block::Custom(block) => matches!(block.placement, BlockPlacement::Above(_)),
+            Block::ExcerptBoundary { next_excerpt, .. } => next_excerpt.is_some(),
+        }
+    }
+
+    fn place_below(&self) -> bool {
+        match self {
+            Block::Custom(block) => matches!(block.placement, BlockPlacement::Below(_)),
+            Block::ExcerptBoundary { next_excerpt, .. } => next_excerpt.is_none(),
+        }
+    }
 }
 
 impl Debug for Block {
@@ -270,6 +376,8 @@ impl Debug for Block {
 struct TransformSummary {
     input_rows: u32,
     output_rows: u32,
+    longest_row: u32,
+    longest_row_chars: u32,
 }
 
 pub struct BlockChunks<'a> {
@@ -298,11 +406,13 @@ impl BlockMap {
         excerpt_footer_height: u32,
     ) -> Self {
         let row_count = wrap_snapshot.max_point().row() + 1;
+        let mut transforms = SumTree::default();
+        push_isomorphic(&mut transforms, row_count, &wrap_snapshot);
         let map = Self {
             next_block_id: AtomicUsize::new(0),
             custom_blocks: Vec::new(),
             custom_blocks_by_id: TreeMap::default(),
-            transforms: RefCell::new(SumTree::from_item(Transform::isomorphic(row_count), &())),
+            transforms: RefCell::new(transforms),
             wrap_snapshot: RefCell::new(wrap_snapshot.clone()),
             show_excerpt_controls,
             buffer_header_height,
@@ -368,28 +478,29 @@ impl BlockMap {
 
         let mut transforms = self.transforms.borrow_mut();
         let mut new_transforms = SumTree::default();
-        let old_row_count = transforms.summary().input_rows;
-        let new_row_count = wrap_snapshot.max_point().row() + 1;
         let mut cursor = transforms.cursor::<WrapRow>(&());
         let mut last_block_ix = 0;
         let mut blocks_in_edit = Vec::new();
         let mut edits = edits.into_iter().peekable();
 
         while let Some(edit) = edits.next() {
-            // Preserve any old transforms that precede this edit.
-            let old_start = WrapRow(edit.old.start);
-            let new_start = WrapRow(edit.new.start);
+            let mut old_start = WrapRow(edit.old.start);
+            let mut new_start = WrapRow(edit.new.start);
+
+            // Preserve transforms that:
+            // * strictly precedes this edit
+            // * isomorphic or replace transforms that end *at* the start of the edit
+            // * below blocks that end at the start of the edit
             new_transforms.append(cursor.slice(&old_start, Bias::Left, &()), &());
             if let Some(transform) = cursor.item() {
-                if transform.is_isomorphic() && old_start == cursor.end(&()) {
+                if transform.summary.input_rows > 0 && cursor.end(&()) == old_start {
+                    // Preserve the transform (push and next)
                     new_transforms.push(transform.clone(), &());
                     cursor.next(&());
+
+                    // Preserve below blocks at end of edit
                     while let Some(transform) = cursor.item() {
-                        if transform
-                            .block
-                            .as_ref()
-                            .map_or(false, |b| b.disposition().is_below())
-                        {
+                        if transform.block.as_ref().map_or(false, |b| b.place_below()) {
                             new_transforms.push(transform.clone(), &());
                             cursor.next(&());
                         } else {
@@ -399,50 +510,70 @@ impl BlockMap {
                 }
             }
 
-            // Preserve any portion of an old transform that precedes this edit.
-            let extent_before_edit = old_start.0 - cursor.start().0;
-            push_isomorphic(&mut new_transforms, extent_before_edit);
+            // Ensure the edit starts at a transform boundary.
+            // If the edit starts within an isomorphic transform, preserve its prefix
+            // If the edit lands within a replacement block, expand the edit to include the start of the replaced input range
+            let mut preserved_blocks_above_edit = false;
+            let transform = cursor.item().unwrap();
+            let transform_rows_before_edit = old_start.0 - cursor.start().0;
+            if transform_rows_before_edit > 0 {
+                if transform.block.is_none() {
+                    // Preserve any portion of the old isomorphic transform that precedes this edit.
+                    push_isomorphic(
+                        &mut new_transforms,
+                        transform_rows_before_edit,
+                        wrap_snapshot,
+                    );
+                } else {
+                    // We landed within a block that replaces some lines, so we
+                    // extend the edit to start at the beginning of the
+                    // replacement.
+                    debug_assert!(transform.summary.input_rows > 0);
+                    old_start.0 -= transform_rows_before_edit;
+                    new_start.0 -= transform_rows_before_edit;
+                    // The blocks *above* it are already in the new transforms, so
+                    // we don't need to re-insert them when querying blocks.
+                    preserved_blocks_above_edit = true;
+                }
+            }
 
-            // Skip over any old transforms that intersect this edit.
+            // Decide where the edit ends
+            // * It should end at a transform boundary
+            // * Coalesce edits that intersect the same transform
             let mut old_end = WrapRow(edit.old.end);
             let mut new_end = WrapRow(edit.new.end);
-            cursor.seek(&old_end, Bias::Left, &());
-            cursor.next(&());
-            if old_end == *cursor.start() {
-                while let Some(transform) = cursor.item() {
-                    if transform
-                        .block
-                        .as_ref()
-                        .map_or(false, |b| b.disposition().is_below())
-                    {
+            loop {
+                // Seek to the transform starting at or after the end of the edit
+                cursor.seek(&old_end, Bias::Left, &());
+                cursor.next(&());
+
+                // Extend edit to the end of the discarded transform so it is reconstructed in full
+                let transform_rows_after_edit = cursor.start().0 - old_end.0;
+                old_end.0 += transform_rows_after_edit;
+                new_end.0 += transform_rows_after_edit;
+
+                // Combine this edit with any subsequent edits that intersect the same transform.
+                while let Some(next_edit) = edits.peek() {
+                    if next_edit.old.start <= cursor.start().0 {
+                        old_end = WrapRow(next_edit.old.end);
+                        new_end = WrapRow(next_edit.new.end);
+                        cursor.seek(&old_end, Bias::Left, &());
                         cursor.next(&());
+                        edits.next();
                     } else {
                         break;
                     }
                 }
+
+                if *cursor.start() == old_end {
+                    break;
+                }
             }
 
-            // Combine this edit with any subsequent edits that intersect the same transform.
-            while let Some(next_edit) = edits.peek() {
-                if next_edit.old.start <= cursor.start().0 {
-                    old_end = WrapRow(next_edit.old.end);
-                    new_end = WrapRow(next_edit.new.end);
-                    cursor.seek(&old_end, Bias::Left, &());
+            // Discard below blocks at the end of the edit. They'll be reconstructed.
+            while let Some(transform) = cursor.item() {
+                if transform.block.as_ref().map_or(false, |b| b.place_below()) {
                     cursor.next(&());
-                    if old_end == *cursor.start() {
-                        while let Some(transform) = cursor.item() {
-                            if transform
-                                .block
-                                .as_ref()
-                                .map_or(false, |b| b.disposition().is_below())
-                            {
-                                cursor.next(&());
-                            } else {
-                                break;
-                            }
-                        }
-                    }
-                    edits.next();
                 } else {
                     break;
                 }
@@ -455,9 +586,10 @@ impl BlockMap {
             let start_block_ix =
                 match self.custom_blocks[last_block_ix..].binary_search_by(|probe| {
                     probe
-                        .position
+                        .start()
                         .to_point(buffer)
                         .cmp(&new_buffer_start)
+                        // Move left until we find the index of the first block starting within this edit
                         .then(Ordering::Greater)
                 }) {
                     Ok(ix) | Err(ix) => last_block_ix + ix,
@@ -473,7 +605,7 @@ impl BlockMap {
                 end_bound = Bound::Excluded(new_buffer_end);
                 match self.custom_blocks[start_block_ix..].binary_search_by(|probe| {
                     probe
-                        .position
+                        .start()
                         .to_point(buffer)
                         .cmp(&new_buffer_end)
                         .then(Ordering::Greater)
@@ -484,19 +616,17 @@ impl BlockMap {
             last_block_ix = end_block_ix;
 
             debug_assert!(blocks_in_edit.is_empty());
-            blocks_in_edit.extend(self.custom_blocks[start_block_ix..end_block_ix].iter().map(
-                |block| {
-                    let mut position = block.position.to_point(buffer);
-                    match block.disposition {
-                        BlockDisposition::Above => position.column = 0,
-                        BlockDisposition::Below => {
-                            position.column = buffer.line_len(MultiBufferRow(position.row))
-                        }
-                    }
-                    let position = wrap_snapshot.make_wrap_point(position, Bias::Left);
-                    (position.row(), Block::Custom(block.clone()))
-                },
-            ));
+
+            blocks_in_edit.extend(
+                self.custom_blocks[start_block_ix..end_block_ix]
+                    .iter()
+                    .filter_map(|block| {
+                        Some((
+                            block.placement.to_wrap_row(wrap_snapshot)?,
+                            Block::Custom(block.clone()),
+                        ))
+                    }),
+            );
 
             if buffer.show_headers() {
                 blocks_in_edit.extend(BlockMap::header_and_footer_blocks(
@@ -514,26 +644,49 @@ impl BlockMap {
 
             // For each of these blocks, insert a new isomorphic transform preceding the block,
             // and then insert the block itself.
-            for (block_row, block) in blocks_in_edit.drain(..) {
-                let insertion_row = match block.disposition() {
-                    BlockDisposition::Above => block_row,
-                    BlockDisposition::Below => block_row + 1,
+            for (block_placement, block) in blocks_in_edit.drain(..) {
+                if preserved_blocks_above_edit
+                    && block_placement == BlockPlacement::Above(new_start)
+                {
+                    continue;
+                }
+
+                let mut summary = TransformSummary {
+                    input_rows: 0,
+                    output_rows: block.height(),
+                    longest_row: 0,
+                    longest_row_chars: 0,
                 };
-                let extent_before_block = insertion_row - new_transforms.summary().input_rows;
-                push_isomorphic(&mut new_transforms, extent_before_block);
-                new_transforms.push(Transform::block(block), &());
-            }
 
-            old_end = WrapRow(old_end.0.min(old_row_count));
-            new_end = WrapRow(new_end.0.min(new_row_count));
+                let rows_before_block;
+                match block_placement {
+                    BlockPlacement::Above(position) => {
+                        rows_before_block = position.0 - new_transforms.summary().input_rows;
+                    }
+                    BlockPlacement::Below(position) => {
+                        rows_before_block = (position.0 + 1) - new_transforms.summary().input_rows;
+                    }
+                    BlockPlacement::Replace(range) => {
+                        rows_before_block = range.start.0 - new_transforms.summary().input_rows;
+                        summary.input_rows = range.end.0 - range.start.0 + 1;
+                    }
+                }
 
-            // Insert an isomorphic transform after the final block.
-            let extent_after_last_block = new_end.0 - new_transforms.summary().input_rows;
-            push_isomorphic(&mut new_transforms, extent_after_last_block);
+                push_isomorphic(&mut new_transforms, rows_before_block, wrap_snapshot);
+                new_transforms.push(
+                    Transform {
+                        summary,
+                        block: Some(block),
+                    },
+                    &(),
+                );
+            }
 
-            // Preserve any portion of the old transform after this edit.
-            let extent_after_edit = cursor.start().0 - old_end.0;
-            push_isomorphic(&mut new_transforms, extent_after_edit);
+            // Insert an isomorphic transform after the final block.
+            let rows_after_last_block = new_end
+                .0
+                .saturating_sub(new_transforms.summary().input_rows);
+            push_isomorphic(&mut new_transforms, rows_after_last_block, wrap_snapshot);
         }
 
         new_transforms.append(cursor.suffix(&()), &());
@@ -558,7 +711,7 @@ impl BlockMap {
         self.show_excerpt_controls
     }
 
-    pub fn header_and_footer_blocks<'a, 'b: 'a, 'c: 'a + 'b, R, T>(
+    fn header_and_footer_blocks<'a, 'b: 'a, 'c: 'a + 'b, R, T>(
         show_excerpt_controls: bool,
         excerpt_footer_height: u32,
         buffer_header_height: u32,
@@ -566,7 +719,7 @@ impl BlockMap {
         buffer: &'b multi_buffer::MultiBufferSnapshot,
         range: R,
         wrap_snapshot: &'c WrapSnapshot,
-    ) -> impl Iterator<Item = (u32, Block)> + 'b
+    ) -> impl Iterator<Item = (BlockPlacement<WrapRow>, Block)> + 'b
     where
         R: RangeBounds<T>,
         T: multi_buffer::ToOffset,
@@ -619,7 +772,11 @@ impl BlockMap {
                 }
 
                 Some((
-                    wrap_row,
+                    if excerpt_boundary.next.is_some() {
+                        BlockPlacement::Above(WrapRow(wrap_row))
+                    } else {
+                        BlockPlacement::Below(WrapRow(wrap_row))
+                    },
                     Block::ExcerptBoundary {
                         prev_excerpt: excerpt_boundary.prev,
                         next_excerpt: excerpt_boundary.next,
@@ -631,45 +788,96 @@ impl BlockMap {
             })
     }
 
-    pub(crate) fn sort_blocks<B: BlockLike>(blocks: &mut [(u32, B)]) {
-        // Place excerpt headers and footers above custom blocks on the same row
-        blocks.sort_unstable_by(|(row_a, block_a), (row_b, block_b)| {
-            row_a.cmp(row_b).then_with(|| {
-                block_a
-                    .disposition()
-                    .cmp(&block_b.disposition())
-                    .then_with(|| match ((block_a.block_type()), (block_b.block_type())) {
-                        (BlockType::ExcerptBoundary, BlockType::ExcerptBoundary) => Ordering::Equal,
-                        (BlockType::ExcerptBoundary, _) => Ordering::Less,
-                        (_, BlockType::ExcerptBoundary) => Ordering::Greater,
-                        (BlockType::Custom(a_id), BlockType::Custom(b_id)) => block_b
-                            .priority()
-                            .cmp(&block_a.priority())
-                            .then_with(|| a_id.cmp(&b_id)),
-                    })
-            })
+    fn sort_blocks(blocks: &mut Vec<(BlockPlacement<WrapRow>, Block)>) {
+        blocks.sort_unstable_by(|(placement_a, block_a), (placement_b, block_b)| {
+            placement_a
+                .cmp(&placement_b)
+                .then_with(|| match (block_a, block_b) {
+                    (
+                        Block::ExcerptBoundary {
+                            next_excerpt: next_excerpt_a,
+                            ..
+                        },
+                        Block::ExcerptBoundary {
+                            next_excerpt: next_excerpt_b,
+                            ..
+                        },
+                    ) => next_excerpt_a
+                        .as_ref()
+                        .map(|excerpt| excerpt.id)
+                        .cmp(&next_excerpt_b.as_ref().map(|excerpt| excerpt.id)),
+                    (Block::ExcerptBoundary { next_excerpt, .. }, Block::Custom(_)) => {
+                        if next_excerpt.is_some() {
+                            Ordering::Less
+                        } else {
+                            Ordering::Greater
+                        }
+                    }
+                    (Block::Custom(_), Block::ExcerptBoundary { next_excerpt, .. }) => {
+                        if next_excerpt.is_some() {
+                            Ordering::Greater
+                        } else {
+                            Ordering::Less
+                        }
+                    }
+                    (Block::Custom(block_a), Block::Custom(block_b)) => block_a
+                        .priority
+                        .cmp(&block_b.priority)
+                        .then_with(|| block_a.id.cmp(&block_b.id)),
+                })
+        });
+        blocks.dedup_by(|(right, _), (left, _)| match (left, right) {
+            (BlockPlacement::Replace(range), BlockPlacement::Above(row)) => {
+                range.start < *row && range.end >= *row
+            }
+            (BlockPlacement::Replace(range), BlockPlacement::Below(row)) => {
+                range.start <= *row && range.end > *row
+            }
+            (BlockPlacement::Replace(range_a), BlockPlacement::Replace(range_b)) => {
+                if range_a.end >= range_b.start && range_a.start <= range_b.end {
+                    range_a.end = range_a.end.max(range_b.end);
+                    true
+                } else {
+                    false
+                }
+            }
+            _ => false,
         });
     }
 }
 
-fn push_isomorphic(tree: &mut SumTree<Transform>, rows: u32) {
+fn push_isomorphic(tree: &mut SumTree<Transform>, rows: u32, wrap_snapshot: &WrapSnapshot) {
     if rows == 0 {
         return;
     }
 
-    let mut extent = Some(rows);
+    let wrap_row_start = tree.summary().input_rows;
+    let wrap_row_end = wrap_row_start + rows;
+    let wrap_summary = wrap_snapshot.text_summary_for_range(wrap_row_start..wrap_row_end);
+    let summary = TransformSummary {
+        input_rows: rows,
+        output_rows: rows,
+        longest_row: wrap_summary.longest_row,
+        longest_row_chars: wrap_summary.longest_row_chars,
+    };
+    let mut merged = false;
     tree.update_last(
         |last_transform| {
-            if last_transform.is_isomorphic() {
-                let extent = extent.take().unwrap();
-                last_transform.summary.input_rows += extent;
-                last_transform.summary.output_rows += extent;
+            if last_transform.block.is_none() {
+                last_transform.summary.add_summary(&summary, &());
+                merged = true;
             }
         },
         &(),
     );
-    if let Some(extent) = extent {
-        tree.push(Transform::isomorphic(extent), &());
+    if !merged {
+        tree.push(
+            Transform {
+                summary,
+                block: None,
+            },
+            &(),
+        );
     }
 }
 
@@ -711,7 +919,7 @@ impl<'a> BlockMapReader<'a> {
     pub fn row_for_block(&self, block_id: CustomBlockId) -> Option<BlockRow> {
         let block = self.blocks.iter().find(|block| block.id == block_id)?;
         let buffer_row = block
-            .position
+            .start()
             .to_point(self.wrap_snapshot.buffer_snapshot())
             .row;
         let wrap_row = self
@@ -735,9 +943,7 @@ impl<'a> BlockMapReader<'a> {
                 break;
             }
 
-            if let Some(BlockType::Custom(id)) =
-                transform.block.as_ref().map(|block| block.block_type())
-            {
+            if let Some(BlockId::Custom(id)) = transform.block.as_ref().map(|block| block.id()) {
                 if id == block_id {
                     return Some(cursor.start().1);
                 }
@@ -762,21 +968,27 @@ impl<'a> BlockMapWriter<'a> {
 
         let mut previous_wrap_row_range: Option<Range<u32>> = None;
         for block in blocks {
+            if let BlockPlacement::Replace(_) = &block.placement {
+                debug_assert!(block.height > 0);
+            }
+
             let id = CustomBlockId(self.0.next_block_id.fetch_add(1, SeqCst));
             ids.push(id);
 
-            let position = block.position;
-            let point = position.to_point(buffer);
-            let wrap_row = wrap_snapshot
-                .make_wrap_point(Point::new(point.row, 0), Bias::Left)
-                .row();
+            let start = block.placement.start().to_point(buffer);
+            let end = block.placement.end().to_point(buffer);
+            let start_wrap_row = wrap_snapshot.make_wrap_point(start, Bias::Left).row();
+            let end_wrap_row = wrap_snapshot.make_wrap_point(end, Bias::Left).row();
 
             let (start_row, end_row) = {
-                previous_wrap_row_range.take_if(|range| !range.contains(&wrap_row));
+                previous_wrap_row_range.take_if(|range| {
+                    !range.contains(&start_wrap_row) || !range.contains(&end_wrap_row)
+                });
                 let range = previous_wrap_row_range.get_or_insert_with(|| {
-                    let start_row = wrap_snapshot.prev_row_boundary(WrapPoint::new(wrap_row, 0));
+                    let start_row =
+                        wrap_snapshot.prev_row_boundary(WrapPoint::new(start_wrap_row, 0));
                     let end_row = wrap_snapshot
-                        .next_row_boundary(WrapPoint::new(wrap_row, 0))
+                        .next_row_boundary(WrapPoint::new(end_wrap_row, 0))
                         .unwrap_or(wrap_snapshot.max_point().row() + 1);
                     start_row..end_row
                 });
@@ -785,16 +997,15 @@ impl<'a> BlockMapWriter<'a> {
             let block_ix = match self
                 .0
                 .custom_blocks
-                .binary_search_by(|probe| probe.position.cmp(&position, buffer))
+                .binary_search_by(|probe| probe.placement.cmp(&block.placement, buffer))
             {
                 Ok(ix) | Err(ix) => ix,
             };
             let new_block = Arc::new(CustomBlock {
                 id,
-                position,
+                placement: block.placement,
                 height: block.height,
                 render: Arc::new(Mutex::new(block.render)),
-                disposition: block.disposition,
                 style: block.style,
                 priority: block.priority,
             });
@@ -819,34 +1030,41 @@ impl<'a> BlockMapWriter<'a> {
 
         for block in &mut self.0.custom_blocks {
             if let Some(new_height) = heights.remove(&block.id) {
+                if let BlockPlacement::Replace(_) = &block.placement {
+                    debug_assert!(new_height > 0);
+                }
+
                 if block.height != new_height {
                     let new_block = CustomBlock {
                         id: block.id,
-                        position: block.position,
+                        placement: block.placement.clone(),
                         height: new_height,
                         style: block.style,
                         render: block.render.clone(),
-                        disposition: block.disposition,
                         priority: block.priority,
                     };
                     let new_block = Arc::new(new_block);
                     *block = new_block.clone();
                     self.0.custom_blocks_by_id.insert(block.id, new_block);
 
-                    let buffer_row = block.position.to_point(buffer).row;
-                    if last_block_buffer_row != Some(buffer_row) {
-                        last_block_buffer_row = Some(buffer_row);
-                        let wrap_row = wrap_snapshot
-                            .make_wrap_point(Point::new(buffer_row, 0), Bias::Left)
+                    let start_row = block.placement.start().to_point(buffer).row;
+                    let end_row = block.placement.end().to_point(buffer).row;
+                    if last_block_buffer_row != Some(end_row) {
+                        last_block_buffer_row = Some(end_row);
+                        let start_wrap_row = wrap_snapshot
+                            .make_wrap_point(Point::new(start_row, 0), Bias::Left)
+                            .row();
+                        let end_wrap_row = wrap_snapshot
+                            .make_wrap_point(Point::new(end_row, 0), Bias::Left)
                             .row();
-                        let start_row =
-                            wrap_snapshot.prev_row_boundary(WrapPoint::new(wrap_row, 0));
-                        let end_row = wrap_snapshot
-                            .next_row_boundary(WrapPoint::new(wrap_row, 0))
+                        let start =
+                            wrap_snapshot.prev_row_boundary(WrapPoint::new(start_wrap_row, 0));
+                        let end = wrap_snapshot
+                            .next_row_boundary(WrapPoint::new(end_wrap_row, 0))
                             .unwrap_or(wrap_snapshot.max_point().row() + 1);
                         edits.push(Edit {
-                            old: start_row..end_row,
-                            new: start_row..end_row,
+                            old: start..end,
+                            new: start..end,
                         })
                     }
                 }
@@ -864,19 +1082,21 @@ impl<'a> BlockMapWriter<'a> {
         let mut previous_wrap_row_range: Option<Range<u32>> = None;
         self.0.custom_blocks.retain(|block| {
             if block_ids.contains(&block.id) {
-                let buffer_row = block.position.to_point(buffer).row;
-                if last_block_buffer_row != Some(buffer_row) {
-                    last_block_buffer_row = Some(buffer_row);
-                    let wrap_row = wrap_snapshot
-                        .make_wrap_point(Point::new(buffer_row, 0), Bias::Left)
-                        .row();
+                let start = block.placement.start().to_point(buffer);
+                let end = block.placement.end().to_point(buffer);
+                if last_block_buffer_row != Some(end.row) {
+                    last_block_buffer_row = Some(end.row);
+                    let start_wrap_row = wrap_snapshot.make_wrap_point(start, Bias::Left).row();
+                    let end_wrap_row = wrap_snapshot.make_wrap_point(end, Bias::Left).row();
                     let (start_row, end_row) = {
-                        previous_wrap_row_range.take_if(|range| !range.contains(&wrap_row));
+                        previous_wrap_row_range.take_if(|range| {
+                            !range.contains(&start_wrap_row) || !range.contains(&end_wrap_row)
+                        });
                         let range = previous_wrap_row_range.get_or_insert_with(|| {
                             let start_row =
-                                wrap_snapshot.prev_row_boundary(WrapPoint::new(wrap_row, 0));
+                                wrap_snapshot.prev_row_boundary(WrapPoint::new(start_wrap_row, 0));
                             let end_row = wrap_snapshot
-                                .next_row_boundary(WrapPoint::new(wrap_row, 0))
+                                .next_row_boundary(WrapPoint::new(end_wrap_row, 0))
                                 .unwrap_or(wrap_snapshot.max_point().row() + 1);
                             start_row..end_row
                         });
@@ -921,31 +1141,24 @@ impl BlockSnapshot {
         highlights: Highlights<'a>,
     ) -> BlockChunks<'a> {
         let max_output_row = cmp::min(rows.end, self.transforms.summary().output_rows);
+
         let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
-        let input_end = {
-            cursor.seek(&BlockRow(rows.end), Bias::Right, &());
-            let overshoot = if cursor
-                .item()
-                .map_or(false, |transform| transform.is_isomorphic())
-            {
-                rows.end - cursor.start().0 .0
-            } else {
-                0
-            };
-            cursor.start().1 .0 + overshoot
-        };
-        let input_start = {
-            cursor.seek(&BlockRow(rows.start), Bias::Right, &());
-            let overshoot = if cursor
-                .item()
-                .map_or(false, |transform| transform.is_isomorphic())
-            {
-                rows.start - cursor.start().0 .0
-            } else {
-                0
-            };
-            cursor.start().1 .0 + overshoot
-        };
+        cursor.seek(&BlockRow(rows.start), Bias::Right, &());
+        let transform_output_start = cursor.start().0 .0;
+        let transform_input_start = cursor.start().1 .0;
+
+        let mut input_start = transform_input_start;
+        let mut input_end = transform_input_start;
+        if let Some(transform) = cursor.item() {
+            if transform.block.is_none() {
+                input_start += rows.start - transform_output_start;
+                input_end += cmp::min(
+                    rows.end - transform_output_start,
+                    transform.summary.input_rows,
+                );
+            }
+        }
+
         BlockChunks {
             input_chunks: self.wrap_snapshot.chunks(
                 input_start..input_end,
@@ -964,7 +1177,10 @@ impl BlockSnapshot {
         let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
         cursor.seek(&start_row, Bias::Right, &());
         let (output_start, input_start) = cursor.start();
-        let overshoot = if cursor.item().map_or(false, |t| t.is_isomorphic()) {
+        let overshoot = if cursor
+            .item()
+            .map_or(false, |transform| transform.block.is_none())
+        {
             start_row.0 - output_start.0
         } else {
             0
@@ -1049,13 +1265,12 @@ impl BlockSnapshot {
     }
 
     pub fn max_point(&self) -> BlockPoint {
-        let row = self.transforms.summary().output_rows - 1;
+        let row = self.transforms.summary().output_rows.saturating_sub(1);
         BlockPoint::new(row, self.line_len(BlockRow(row)))
     }
 
     pub fn longest_row(&self) -> u32 {
-        let input_row = self.wrap_snapshot.longest_row();
-        self.to_block_point(WrapPoint::new(input_row, 0)).row
+        self.transforms.summary().longest_row
     }
 
     pub(super) fn line_len(&self, row: BlockRow) -> u32 {
@@ -1069,6 +1284,8 @@ impl BlockSnapshot {
             } else {
                 self.wrap_snapshot.line_len(input_start.0 + overshoot)
             }
+        } else if row.0 == 0 {
+            0
         } else {
             panic!("row out of range");
         }
@@ -1091,26 +1308,40 @@ impl BlockSnapshot {
 
         loop {
             if let Some(transform) = cursor.item() {
-                if transform.is_isomorphic() {
-                    let (output_start_row, input_start_row) = cursor.start();
-                    let (output_end_row, input_end_row) = cursor.end(&());
-                    let output_start = Point::new(output_start_row.0, 0);
-                    let input_start = Point::new(input_start_row.0, 0);
-                    let input_end = Point::new(input_end_row.0, 0);
-                    let input_point = if point.row >= output_end_row.0 {
-                        let line_len = self.wrap_snapshot.line_len(input_end_row.0 - 1);
-                        self.wrap_snapshot
-                            .clip_point(WrapPoint::new(input_end_row.0 - 1, line_len), bias)
-                    } else {
-                        let output_overshoot = point.0.saturating_sub(output_start);
-                        self.wrap_snapshot
-                            .clip_point(WrapPoint(input_start + output_overshoot), bias)
-                    };
-
-                    if (input_start..input_end).contains(&input_point.0) {
-                        let input_overshoot = input_point.0.saturating_sub(input_start);
-                        return BlockPoint(output_start + input_overshoot);
+                let (output_start_row, input_start_row) = cursor.start();
+                let (output_end_row, input_end_row) = cursor.end(&());
+                let output_start = Point::new(output_start_row.0, 0);
+                let output_end = Point::new(output_end_row.0, 0);
+                let input_start = Point::new(input_start_row.0, 0);
+                let input_end = Point::new(input_end_row.0, 0);
+
+                match transform.block.as_ref() {
+                    Some(Block::Custom(block))
+                        if matches!(block.placement, BlockPlacement::Replace(_)) =>
+                    {
+                        if bias == Bias::Left {
+                            return BlockPoint(output_start);
+                        } else {
+                            return BlockPoint(Point::new(output_end.row - 1, 0));
+                        }
                     }
+                    None => {
+                        let input_point = if point.row >= output_end_row.0 {
+                            let line_len = self.wrap_snapshot.line_len(input_end_row.0 - 1);
+                            self.wrap_snapshot
+                                .clip_point(WrapPoint::new(input_end_row.0 - 1, line_len), bias)
+                        } else {
+                            let output_overshoot = point.0.saturating_sub(output_start);
+                            self.wrap_snapshot
+                                .clip_point(WrapPoint(input_start + output_overshoot), bias)
+                        };
+
+                        if (input_start..input_end).contains(&input_point.0) {
+                            let input_overshoot = input_point.0.saturating_sub(input_start);
+                            return BlockPoint(output_start + input_overshoot);
+                        }
+                    }
+                    _ => {}
                 }
 
                 if search_left {

crates/editor/src/display_map/char_map.rs 🔗

@@ -252,6 +252,7 @@ impl CharSnapshot {
         };
 
         TabChunks {
+            snapshot: self,
             fold_chunks: self.fold_snapshot.chunks(
                 input_start..input_end,
                 language_aware,
@@ -492,6 +493,7 @@ impl<'a> std::ops::AddAssign<&'a Self> for TextSummary {
 const SPACES: &str = "                ";
 
 pub struct TabChunks<'a> {
+    snapshot: &'a CharSnapshot,
     fold_chunks: FoldChunks<'a>,
     chunk: Chunk<'a>,
     column: u32,
@@ -503,6 +505,37 @@ pub struct TabChunks<'a> {
     inside_leading_tab: bool,
 }
 
+impl<'a> TabChunks<'a> {
+    pub(crate) fn seek(&mut self, range: Range<CharPoint>) {
+        let (input_start, expanded_char_column, to_next_stop) =
+            self.snapshot.to_fold_point(range.start, Bias::Left);
+        let input_column = input_start.column();
+        let input_start = input_start.to_offset(&self.snapshot.fold_snapshot);
+        let input_end = self
+            .snapshot
+            .to_fold_point(range.end, Bias::Right)
+            .0
+            .to_offset(&self.snapshot.fold_snapshot);
+        let to_next_stop = if range.start.0 + Point::new(0, to_next_stop) > range.end.0 {
+            range.end.column() - range.start.column()
+        } else {
+            to_next_stop
+        };
+
+        self.fold_chunks.seek(input_start..input_end);
+        self.input_column = input_column;
+        self.column = expanded_char_column;
+        self.output_position = range.start.0;
+        self.max_output_position = range.end.0;
+        self.chunk = Chunk {
+            text: &SPACES[0..(to_next_stop as usize)],
+            is_tab: true,
+            ..Default::default()
+        };
+        self.inside_leading_tab = to_next_stop > 0;
+    }
+}
+
 impl<'a> Iterator for TabChunks<'a> {
     type Item = Chunk<'a>;
 

crates/editor/src/display_map/fold_map.rs 🔗

@@ -1100,6 +1100,17 @@ pub struct FoldBufferRows<'a> {
     fold_point: FoldPoint,
 }
 
+impl<'a> FoldBufferRows<'a> {
+    pub(crate) fn seek(&mut self, row: u32) {
+        let fold_point = FoldPoint::new(row, 0);
+        self.cursor.seek(&fold_point, Bias::Left, &());
+        let overshoot = fold_point.0 - self.cursor.start().0 .0;
+        let inlay_point = InlayPoint(self.cursor.start().1 .0 + overshoot);
+        self.input_buffer_rows.seek(inlay_point.row());
+        self.fold_point = fold_point;
+    }
+}
+
 impl<'a> Iterator for FoldBufferRows<'a> {
     type Item = Option<u32>;
 
@@ -1135,6 +1146,38 @@ pub struct FoldChunks<'a> {
     max_output_offset: FoldOffset,
 }
 
+impl<'a> FoldChunks<'a> {
+    pub(crate) fn seek(&mut self, range: Range<FoldOffset>) {
+        self.transform_cursor.seek(&range.start, Bias::Right, &());
+
+        let inlay_start = {
+            let overshoot = range.start.0 - self.transform_cursor.start().0 .0;
+            self.transform_cursor.start().1 + InlayOffset(overshoot)
+        };
+
+        let transform_end = self.transform_cursor.end(&());
+
+        let inlay_end = if self
+            .transform_cursor
+            .item()
+            .map_or(true, |transform| transform.is_fold())
+        {
+            inlay_start
+        } else if range.end < transform_end.0 {
+            let overshoot = range.end.0 - self.transform_cursor.start().0 .0;
+            self.transform_cursor.start().1 + InlayOffset(overshoot)
+        } else {
+            transform_end.1
+        };
+
+        self.inlay_chunks.seek(inlay_start..inlay_end);
+        self.inlay_chunk = None;
+        self.inlay_offset = inlay_start;
+        self.output_offset = range.start;
+        self.max_output_offset = range.end;
+    }
+}
+
 impl<'a> Iterator for FoldChunks<'a> {
     type Item = Chunk<'a>;
 

crates/editor/src/display_map/wrap_map.rs 🔗

@@ -56,6 +56,7 @@ pub struct WrapChunks<'a> {
     output_position: WrapPoint,
     max_output_row: u32,
     transforms: Cursor<'a, Transform, (WrapPoint, CharPoint)>,
+    snapshot: &'a WrapSnapshot,
 }
 
 #[derive(Clone)]
@@ -68,6 +69,21 @@ pub struct WrapBufferRows<'a> {
     transforms: Cursor<'a, Transform, (WrapPoint, CharPoint)>,
 }
 
+impl<'a> WrapBufferRows<'a> {
+    pub(crate) fn seek(&mut self, start_row: u32) {
+        self.transforms
+            .seek(&WrapPoint::new(start_row, 0), Bias::Left, &());
+        let mut input_row = self.transforms.start().1.row();
+        if self.transforms.item().map_or(false, |t| t.is_isomorphic()) {
+            input_row += start_row - self.transforms.start().0.row();
+        }
+        self.soft_wrapped = self.transforms.item().map_or(false, |t| !t.is_isomorphic());
+        self.input_buffer_rows.seek(input_row);
+        self.input_buffer_row = self.input_buffer_rows.next().unwrap();
+        self.output_row = start_row;
+    }
+}
+
 impl WrapMap {
     pub fn new(
         char_snapshot: CharSnapshot,
@@ -602,6 +618,7 @@ impl WrapSnapshot {
             output_position: output_start,
             max_output_row: rows.end,
             transforms,
+            snapshot: self,
         }
     }
 
@@ -629,6 +646,67 @@ impl WrapSnapshot {
         }
     }
 
+    pub fn text_summary_for_range(&self, rows: Range<u32>) -> TextSummary {
+        let mut summary = TextSummary::default();
+
+        let start = WrapPoint::new(rows.start, 0);
+        let end = WrapPoint::new(rows.end, 0);
+
+        let mut cursor = self.transforms.cursor::<(WrapPoint, CharPoint)>(&());
+        cursor.seek(&start, Bias::Right, &());
+        if let Some(transform) = cursor.item() {
+            let start_in_transform = start.0 - cursor.start().0 .0;
+            let end_in_transform = cmp::min(end, cursor.end(&()).0).0 - cursor.start().0 .0;
+            if transform.is_isomorphic() {
+                let char_start = CharPoint(cursor.start().1 .0 + start_in_transform);
+                let char_end = CharPoint(cursor.start().1 .0 + end_in_transform);
+                summary += &self
+                    .char_snapshot
+                    .text_summary_for_range(char_start..char_end);
+            } else {
+                debug_assert_eq!(start_in_transform.row, end_in_transform.row);
+                let indent_len = end_in_transform.column - start_in_transform.column;
+                summary += &TextSummary {
+                    lines: Point::new(0, indent_len),
+                    first_line_chars: indent_len,
+                    last_line_chars: indent_len,
+                    longest_row: 0,
+                    longest_row_chars: indent_len,
+                };
+            }
+
+            cursor.next(&());
+        }
+
+        if rows.end > cursor.start().0.row() {
+            summary += &cursor
+                .summary::<_, TransformSummary>(&WrapPoint::new(rows.end, 0), Bias::Right, &())
+                .output;
+
+            if let Some(transform) = cursor.item() {
+                let end_in_transform = end.0 - cursor.start().0 .0;
+                if transform.is_isomorphic() {
+                    let char_start = cursor.start().1;
+                    let char_end = CharPoint(char_start.0 + end_in_transform);
+                    summary += &self
+                        .char_snapshot
+                        .text_summary_for_range(char_start..char_end);
+                } else {
+                    debug_assert_eq!(end_in_transform, Point::new(1, 0));
+                    summary += &TextSummary {
+                        lines: Point::new(1, 0),
+                        first_line_chars: 0,
+                        last_line_chars: 0,
+                        longest_row: 0,
+                        longest_row_chars: 0,
+                    };
+                }
+            }
+        }
+
+        summary
+    }
+
     pub fn soft_wrap_indent(&self, row: u32) -> Option<u32> {
         let mut cursor = self.transforms.cursor::<WrapPoint>(&());
         cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Right, &());
@@ -745,6 +823,21 @@ impl WrapSnapshot {
         None
     }
 
+    #[cfg(test)]
+    pub fn text(&self) -> String {
+        self.text_chunks(0).collect()
+    }
+
+    #[cfg(test)]
+    pub fn text_chunks(&self, wrap_row: u32) -> impl Iterator<Item = &str> {
+        self.chunks(
+            wrap_row..self.max_point().row() + 1,
+            false,
+            Highlights::default(),
+        )
+        .map(|h| h.text)
+    }
+
     fn check_invariants(&self) {
         #[cfg(test)]
         {
@@ -791,6 +884,26 @@ impl WrapSnapshot {
     }
 }
 
+impl<'a> WrapChunks<'a> {
+    pub(crate) fn seek(&mut self, rows: Range<u32>) {
+        let output_start = WrapPoint::new(rows.start, 0);
+        let output_end = WrapPoint::new(rows.end, 0);
+        self.transforms.seek(&output_start, Bias::Right, &());
+        let mut input_start = CharPoint(self.transforms.start().1 .0);
+        if self.transforms.item().map_or(false, |t| t.is_isomorphic()) {
+            input_start.0 += output_start.0 - self.transforms.start().0 .0;
+        }
+        let input_end = self
+            .snapshot
+            .to_char_point(output_end)
+            .min(self.snapshot.char_snapshot.max_point());
+        self.input_chunks.seek(input_start..input_end);
+        self.input_chunk = Chunk::default();
+        self.output_position = output_start;
+        self.max_output_row = rows.end;
+    }
+}
+
 impl<'a> Iterator for WrapChunks<'a> {
     type Item = Chunk<'a>;
 
@@ -1336,19 +1449,6 @@ mod tests {
     }
 
     impl WrapSnapshot {
-        pub fn text(&self) -> String {
-            self.text_chunks(0).collect()
-        }
-
-        pub fn text_chunks(&self, wrap_row: u32) -> impl Iterator<Item = &str> {
-            self.chunks(
-                wrap_row..self.max_point().row() + 1,
-                false,
-                Highlights::default(),
-            )
-            .map(|h| h.text)
-        }
-
         fn verify_chunks(&mut self, rng: &mut impl Rng) {
             for _ in 0..5 {
                 let mut end_row = rng.gen_range(0..=self.max_point().row());

crates/editor/src/editor.rs 🔗

@@ -10210,7 +10210,7 @@ impl Editor {
                     let block_id = this.insert_blocks(
                         [BlockProperties {
                             style: BlockStyle::Flex,
-                            position: range.start,
+                            placement: BlockPlacement::Below(range.start),
                             height: 1,
                             render: Box::new({
                                 let rename_editor = rename_editor.clone();
@@ -10246,7 +10246,6 @@ impl Editor {
                                         .into_any_element()
                                 }
                             }),
-                            disposition: BlockDisposition::Below,
                             priority: 0,
                         }],
                         Some(Autoscroll::fit()),
@@ -10531,10 +10530,11 @@ impl Editor {
                         let message_height = diagnostic.message.matches('\n').count() as u32 + 1;
                         BlockProperties {
                             style: BlockStyle::Fixed,
-                            position: buffer.anchor_after(entry.range.start),
+                            placement: BlockPlacement::Below(
+                                buffer.anchor_after(entry.range.start),
+                            ),
                             height: message_height,
                             render: diagnostic_block_renderer(diagnostic, None, true, true),
-                            disposition: BlockDisposition::Below,
                             priority: 0,
                         }
                     }),

crates/editor/src/editor_tests.rs 🔗

@@ -3868,8 +3868,7 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) {
         editor.insert_blocks(
             [BlockProperties {
                 style: BlockStyle::Fixed,
-                position: snapshot.anchor_after(Point::new(2, 0)),
-                disposition: BlockDisposition::Below,
+                placement: BlockPlacement::Below(snapshot.anchor_after(Point::new(2, 0))),
                 height: 1,
                 render: Box::new(|_| div().into_any()),
                 priority: 0,

crates/editor/src/element.rs 🔗

@@ -2071,7 +2071,7 @@ impl EditorElement {
         let mut element = match block {
             Block::Custom(block) => {
                 let align_to = block
-                    .position()
+                    .start()
                     .to_point(&snapshot.buffer_snapshot)
                     .to_display_point(snapshot);
                 let anchor_x = text_x
@@ -6294,7 +6294,7 @@ fn compute_auto_height_layout(
 mod tests {
     use super::*;
     use crate::{
-        display_map::{BlockDisposition, BlockProperties},
+        display_map::{BlockPlacement, BlockProperties},
         editor_tests::{init_test, update_test_language_settings},
         Editor, MultiBuffer,
     };
@@ -6550,9 +6550,8 @@ mod tests {
                 editor.insert_blocks(
                     [BlockProperties {
                         style: BlockStyle::Fixed,
-                        disposition: BlockDisposition::Above,
+                        placement: BlockPlacement::Above(Anchor::min()),
                         height: 3,
-                        position: Anchor::min(),
                         render: Box::new(|cx| div().h(3. * cx.line_height()).into_any()),
                         priority: 0,
                     }],

crates/editor/src/hunk_diff.rs 🔗

@@ -17,7 +17,7 @@ use workspace::Item;
 
 use crate::{
     editor_settings::CurrentLineHighlight, hunk_status, hunks_for_selections, ApplyDiffHunk,
-    BlockDisposition, BlockProperties, BlockStyle, CustomBlockId, DiffRowHighlight, DisplayRow,
+    BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, DiffRowHighlight, DisplayRow,
     DisplaySnapshot, Editor, EditorElement, ExpandAllHunkDiffs, GoToHunk, GoToPrevHunk, RevertFile,
     RevertSelectedHunks, ToDisplayPoint, ToggleHunkDiff,
 };
@@ -417,10 +417,9 @@ impl Editor {
         };
 
         BlockProperties {
-            position: hunk.multi_buffer_range.start,
+            placement: BlockPlacement::Above(hunk.multi_buffer_range.start),
             height: 1,
             style: BlockStyle::Sticky,
-            disposition: BlockDisposition::Above,
             priority: 0,
             render: Box::new({
                 let editor = cx.view().clone();
@@ -700,10 +699,9 @@ impl Editor {
         let hunk = hunk.clone();
         let height = editor_height.max(deleted_text_height);
         BlockProperties {
-            position: hunk.multi_buffer_range.start,
+            placement: BlockPlacement::Above(hunk.multi_buffer_range.start),
             height,
             style: BlockStyle::Flex,
-            disposition: BlockDisposition::Above,
             priority: 0,
             render: Box::new(move |cx| {
                 let width = EditorElement::diff_hunk_strip_width(cx.line_height());

crates/repl/src/session.rs 🔗

@@ -8,7 +8,7 @@ use client::telemetry::Telemetry;
 use collections::{HashMap, HashSet};
 use editor::{
     display_map::{
-        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, CustomBlockId,
+        BlockContext, BlockId, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId,
         RenderBlock,
     },
     scroll::Autoscroll,
@@ -90,12 +90,11 @@ impl EditorBlock {
 
             let invalidation_anchor = buffer.read(cx).read(cx).anchor_before(next_row_start);
             let block = BlockProperties {
-                position: code_range.end,
+                placement: BlockPlacement::Below(code_range.end),
                 // Take up at least one height for status, allow the editor to determine the real height based on the content from render
                 height: 1,
                 style: BlockStyle::Sticky,
                 render: Self::create_output_area_renderer(execution_view.clone(), on_close.clone()),
-                disposition: BlockDisposition::Below,
                 priority: 0,
             };