Combine excerpt footer and header into a single block (#19441)

This simplifies rendering of excerpt headers and footers, and removes
the need to store a `BlockDisposition` on these boundary blocks. It's a
step toward implementing "replace blocks", which we want to use in the
assistant panel.

We've also cleaned up the way heights are specified for headers and
footers and fixed some visual asymmetries between the "expand upward"
and "expand downward" buttons.

Release Notes:

- N/A

---------

Co-authored-by: Richard <richard@zed.dev>
This commit is contained in:
Max Brunsfeld 2024-10-18 17:58:07 -07:00 committed by GitHub
parent 3e0c5c10b7
commit d209eab058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 401 additions and 553 deletions

View file

@ -962,7 +962,6 @@ fn random_diagnostic(
const FILE_HEADER: &str = "file header"; const FILE_HEADER: &str = "file header";
const EXCERPT_HEADER: &str = "excerpt header"; const EXCERPT_HEADER: &str = "excerpt header";
const EXCERPT_FOOTER: &str = "excerpt footer";
fn editor_blocks( fn editor_blocks(
editor: &View<Editor>, editor: &View<Editor>,
@ -998,7 +997,7 @@ fn editor_blocks(
.ok()? .ok()?
} }
Block::ExcerptHeader { Block::ExcerptBoundary {
starts_new_buffer, .. starts_new_buffer, ..
} => { } => {
if *starts_new_buffer { if *starts_new_buffer {
@ -1007,7 +1006,6 @@ fn editor_blocks(
EXCERPT_HEADER.into() EXCERPT_HEADER.into()
} }
} }
Block::ExcerptFooter { .. } => EXCERPT_FOOTER.into(),
}; };
Some((row, name)) Some((row, name))

View file

@ -5,8 +5,8 @@ use super::{
use crate::{EditorStyle, GutterDimensions}; use crate::{EditorStyle, GutterDimensions};
use collections::{Bound, HashMap, HashSet}; use collections::{Bound, HashMap, HashSet};
use gpui::{AnyElement, EntityId, Pixels, WindowContext}; use gpui::{AnyElement, EntityId, Pixels, WindowContext};
use language::{BufferSnapshot, Chunk, Patch, Point}; use language::{Chunk, Patch, Point};
use multi_buffer::{Anchor, ExcerptId, ExcerptRange, MultiBufferRow, ToPoint as _}; use multi_buffer::{Anchor, ExcerptId, ExcerptInfo, MultiBufferRow, ToPoint as _};
use parking_lot::Mutex; use parking_lot::Mutex;
use std::{ use std::{
cell::RefCell, cell::RefCell,
@ -128,26 +128,17 @@ pub struct BlockContext<'a, 'b> {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BlockId { pub enum BlockId {
Custom(CustomBlockId), Custom(CustomBlockId),
ExcerptHeader(ExcerptId), ExcerptBoundary(Option<ExcerptId>),
ExcerptFooter(ExcerptId),
}
impl From<BlockId> for EntityId {
fn from(value: BlockId) -> Self {
match value {
BlockId::Custom(CustomBlockId(id)) => EntityId::from(id as u64),
BlockId::ExcerptHeader(id) => id.into(),
BlockId::ExcerptFooter(id) => id.into(),
}
}
} }
impl From<BlockId> for ElementId { impl From<BlockId> for ElementId {
fn from(value: BlockId) -> Self { fn from(value: BlockId) -> Self {
match value { match value {
BlockId::Custom(CustomBlockId(id)) => ("Block", id).into(), BlockId::Custom(CustomBlockId(id)) => ("Block", id).into(),
BlockId::ExcerptHeader(id) => ("ExcerptHeader", EntityId::from(id)).into(), BlockId::ExcerptBoundary(next_excerpt) => match next_excerpt {
BlockId::ExcerptFooter(id) => ("ExcerptFooter", EntityId::from(id)).into(), Some(id) => ("ExcerptBoundary", EntityId::from(id)).into(),
None => "LastExcerptBoundary".into(),
},
} }
} }
} }
@ -156,8 +147,7 @@ impl std::fmt::Display for BlockId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Custom(id) => write!(f, "Block({id:?})"), Self::Custom(id) => write!(f, "Block({id:?})"),
Self::ExcerptHeader(id) => write!(f, "ExcerptHeader({id:?})"), Self::ExcerptBoundary(id) => write!(f, "ExcerptHeader({id:?})"),
Self::ExcerptFooter(id) => write!(f, "ExcerptFooter({id:?})"),
} }
} }
} }
@ -177,8 +167,7 @@ struct Transform {
pub(crate) enum BlockType { pub(crate) enum BlockType {
Custom(CustomBlockId), Custom(CustomBlockId),
Header, ExcerptBoundary,
Footer,
} }
pub(crate) trait BlockLike { pub(crate) trait BlockLike {
@ -191,27 +180,20 @@ pub(crate) trait BlockLike {
#[derive(Clone)] #[derive(Clone)]
pub enum Block { pub enum Block {
Custom(Arc<CustomBlock>), Custom(Arc<CustomBlock>),
ExcerptHeader { ExcerptBoundary {
id: ExcerptId, prev_excerpt: Option<ExcerptInfo>,
buffer: BufferSnapshot, next_excerpt: Option<ExcerptInfo>,
range: ExcerptRange<text::Anchor>,
height: u32, height: u32,
starts_new_buffer: bool, starts_new_buffer: bool,
show_excerpt_controls: bool, show_excerpt_controls: bool,
}, },
ExcerptFooter {
id: ExcerptId,
disposition: BlockDisposition,
height: u32,
},
} }
impl BlockLike for Block { impl BlockLike for Block {
fn block_type(&self) -> BlockType { fn block_type(&self) -> BlockType {
match self { match self {
Block::Custom(block) => BlockType::Custom(block.id), Block::Custom(block) => BlockType::Custom(block.id),
Block::ExcerptHeader { .. } => BlockType::Header, Block::ExcerptBoundary { .. } => BlockType::ExcerptBoundary,
Block::ExcerptFooter { .. } => BlockType::Footer,
} }
} }
@ -222,8 +204,7 @@ impl BlockLike for Block {
fn priority(&self) -> usize { fn priority(&self) -> usize {
match self { match self {
Block::Custom(block) => block.priority, Block::Custom(block) => block.priority,
Block::ExcerptHeader { .. } => usize::MAX, Block::ExcerptBoundary { .. } => usize::MAX,
Block::ExcerptFooter { .. } => 0,
} }
} }
} }
@ -232,32 +213,36 @@ impl Block {
pub fn id(&self) -> BlockId { pub fn id(&self) -> BlockId {
match self { match self {
Block::Custom(block) => BlockId::Custom(block.id), Block::Custom(block) => BlockId::Custom(block.id),
Block::ExcerptHeader { id, .. } => BlockId::ExcerptHeader(*id), Block::ExcerptBoundary { next_excerpt, .. } => {
Block::ExcerptFooter { id, .. } => BlockId::ExcerptFooter(*id), BlockId::ExcerptBoundary(next_excerpt.as_ref().map(|info| info.id))
}
} }
} }
fn disposition(&self) -> BlockDisposition { fn disposition(&self) -> BlockDisposition {
match self { match self {
Block::Custom(block) => block.disposition, Block::Custom(block) => block.disposition,
Block::ExcerptHeader { .. } => BlockDisposition::Above, Block::ExcerptBoundary { next_excerpt, .. } => {
Block::ExcerptFooter { disposition, .. } => *disposition, if next_excerpt.is_some() {
BlockDisposition::Above
} else {
BlockDisposition::Below
}
}
} }
} }
pub fn height(&self) -> u32 { pub fn height(&self) -> u32 {
match self { match self {
Block::Custom(block) => block.height, Block::Custom(block) => block.height,
Block::ExcerptHeader { height, .. } => *height, Block::ExcerptBoundary { height, .. } => *height,
Block::ExcerptFooter { height, .. } => *height,
} }
} }
pub fn style(&self) -> BlockStyle { pub fn style(&self) -> BlockStyle {
match self { match self {
Block::Custom(block) => block.style, Block::Custom(block) => block.style,
Block::ExcerptHeader { .. } => BlockStyle::Sticky, Block::ExcerptBoundary { .. } => BlockStyle::Sticky,
Block::ExcerptFooter { .. } => BlockStyle::Sticky,
} }
} }
} }
@ -266,24 +251,17 @@ impl Debug for Block {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Custom(block) => f.debug_struct("Custom").field("block", block).finish(), Self::Custom(block) => f.debug_struct("Custom").field("block", block).finish(),
Self::ExcerptHeader { Self::ExcerptBoundary {
buffer,
starts_new_buffer, starts_new_buffer,
id, next_excerpt,
prev_excerpt,
.. ..
} => f } => f
.debug_struct("ExcerptHeader") .debug_struct("ExcerptBoundary")
.field("id", &id) .field("prev_excerpt", &prev_excerpt)
.field("path", &buffer.file().map(|f| f.path())) .field("next_excerpt", &next_excerpt)
.field("starts_new_buffer", &starts_new_buffer) .field("starts_new_buffer", &starts_new_buffer)
.finish(), .finish(),
Block::ExcerptFooter {
id, disposition, ..
} => f
.debug_struct("ExcerptFooter")
.field("id", &id)
.field("disposition", &disposition)
.finish(),
} }
} }
} }
@ -595,17 +573,12 @@ impl BlockMap {
{ {
buffer buffer
.excerpt_boundaries_in_range(range) .excerpt_boundaries_in_range(range)
.flat_map(move |excerpt_boundary| { .filter_map(move |excerpt_boundary| {
let mut wrap_row = wrap_snapshot let wrap_row;
if excerpt_boundary.next.is_some() {
wrap_row = wrap_snapshot
.make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left) .make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left)
.row(); .row();
[
show_excerpt_controls
.then(|| {
let disposition;
if excerpt_boundary.next.is_some() {
disposition = BlockDisposition::Above;
} else { } else {
wrap_row = wrap_snapshot wrap_row = wrap_snapshot
.make_wrap_point( .make_wrap_point(
@ -616,45 +589,46 @@ impl BlockMap {
Bias::Left, Bias::Left,
) )
.row(); .row();
disposition = BlockDisposition::Below;
} }
excerpt_boundary.prev.as_ref().map(|prev| { let starts_new_buffer = match (&excerpt_boundary.prev, &excerpt_boundary.next) {
( (_, None) => false,
wrap_row, (None, Some(_)) => true,
Block::ExcerptFooter { (Some(prev), Some(next)) => prev.buffer_id != next.buffer_id,
id: prev.id, };
height: excerpt_footer_height,
disposition,
},
)
})
})
.flatten(),
excerpt_boundary.next.map(|next| {
let starts_new_buffer = excerpt_boundary
.prev
.map_or(true, |prev| prev.buffer_id != next.buffer_id);
( let mut height = 0;
wrap_row, if excerpt_boundary.prev.is_some() {
Block::ExcerptHeader { if show_excerpt_controls {
id: next.id, height += excerpt_footer_height;
buffer: next.buffer, }
range: next.range, }
height: if starts_new_buffer { if excerpt_boundary.next.is_some() {
buffer_header_height if starts_new_buffer {
height += buffer_header_height;
if show_excerpt_controls {
height += excerpt_header_height;
}
} else { } else {
excerpt_header_height height += excerpt_header_height;
}, }
}
if height == 0 {
return None;
}
Some((
wrap_row,
Block::ExcerptBoundary {
prev_excerpt: excerpt_boundary.prev,
next_excerpt: excerpt_boundary.next,
height,
starts_new_buffer, starts_new_buffer,
show_excerpt_controls, show_excerpt_controls,
}, },
) ))
}),
]
}) })
.flatten()
} }
pub(crate) fn sort_blocks<B: BlockLike>(blocks: &mut [(u32, B)]) { pub(crate) fn sort_blocks<B: BlockLike>(blocks: &mut [(u32, B)]) {
@ -665,12 +639,9 @@ impl BlockMap {
.disposition() .disposition()
.cmp(&block_b.disposition()) .cmp(&block_b.disposition())
.then_with(|| match ((block_a.block_type()), (block_b.block_type())) { .then_with(|| match ((block_a.block_type()), (block_b.block_type())) {
(BlockType::Footer, BlockType::Footer) => Ordering::Equal, (BlockType::ExcerptBoundary, BlockType::ExcerptBoundary) => Ordering::Equal,
(BlockType::Footer, _) => Ordering::Less, (BlockType::ExcerptBoundary, _) => Ordering::Less,
(_, BlockType::Footer) => Ordering::Greater, (_, BlockType::ExcerptBoundary) => Ordering::Greater,
(BlockType::Header, BlockType::Header) => Ordering::Equal,
(BlockType::Header, _) => Ordering::Less,
(_, BlockType::Header) => Ordering::Greater,
(BlockType::Custom(a_id), BlockType::Custom(b_id)) => block_b (BlockType::Custom(a_id), BlockType::Custom(b_id)) => block_b
.priority() .priority()
.cmp(&block_a.priority()) .cmp(&block_a.priority())
@ -1045,32 +1016,18 @@ impl BlockSnapshot {
let custom_block = self.custom_blocks_by_id.get(&custom_block_id)?; let custom_block = self.custom_blocks_by_id.get(&custom_block_id)?;
Some(Block::Custom(custom_block.clone())) Some(Block::Custom(custom_block.clone()))
} }
BlockId::ExcerptHeader(excerpt_id) => { BlockId::ExcerptBoundary(next_excerpt_id) => {
let excerpt_range = buffer.range_for_excerpt::<Point>(excerpt_id)?; let wrap_point;
let wrap_point = self if let Some(next_excerpt_id) = next_excerpt_id {
let excerpt_range = buffer.range_for_excerpt::<Point>(next_excerpt_id)?;
wrap_point = self
.wrap_snapshot .wrap_snapshot
.make_wrap_point(excerpt_range.start, Bias::Left); .make_wrap_point(excerpt_range.start, Bias::Left);
let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&()); } else {
cursor.seek(&WrapRow(wrap_point.row()), Bias::Left, &()); wrap_point = self
while let Some(transform) = cursor.item() {
if let Some(block) = transform.block.as_ref() {
if block.id() == block_id {
return Some(block.clone());
}
} else if cursor.start().0 > WrapRow(wrap_point.row()) {
break;
}
cursor.next(&());
}
None
}
BlockId::ExcerptFooter(excerpt_id) => {
let excerpt_range = buffer.range_for_excerpt::<Point>(excerpt_id)?;
let wrap_point = self
.wrap_snapshot .wrap_snapshot
.make_wrap_point(excerpt_range.end, Bias::Left); .make_wrap_point(buffer.max_point(), Bias::Left);
}
let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&()); let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&());
cursor.seek(&WrapRow(wrap_point.row()), Bias::Left, &()); cursor.seek(&WrapRow(wrap_point.row()), Bias::Left, &());
@ -1468,7 +1425,7 @@ mod tests {
}; };
use gpui::{div, font, px, AppContext, Context as _, Element}; use gpui::{div, font, px, AppContext, Context as _, Element};
use language::{Buffer, Capability}; use language::{Buffer, Capability};
use multi_buffer::MultiBuffer; use multi_buffer::{ExcerptRange, MultiBuffer};
use rand::prelude::*; use rand::prelude::*;
use settings::SettingsStore; use settings::SettingsStore;
use std::env; use std::env;
@ -1724,22 +1681,20 @@ mod tests {
// Each excerpt has a header above and footer below. Excerpts are also *separated* by a newline. // Each excerpt has a header above and footer below. Excerpts are also *separated* by a newline.
assert_eq!( assert_eq!(
snapshot.text(), snapshot.text(),
"\nBuff\ner 1\n\n\nBuff\ner 2\n\n\nBuff\ner 3\n" "\n\nBuff\ner 1\n\n\n\nBuff\ner 2\n\n\n\nBuff\ner 3\n"
); );
let blocks: Vec<_> = snapshot let blocks: Vec<_> = snapshot
.blocks_in_range(0..u32::MAX) .blocks_in_range(0..u32::MAX)
.map(|(row, block)| (row, block.id())) .map(|(row, block)| (row..row + block.height(), block.id()))
.collect(); .collect();
assert_eq!( assert_eq!(
blocks, blocks,
vec![ vec![
(0, BlockId::ExcerptHeader(excerpt_ids[0])), (0..2, BlockId::ExcerptBoundary(Some(excerpt_ids[0]))), // path, header
(3, BlockId::ExcerptFooter(excerpt_ids[0])), (4..7, BlockId::ExcerptBoundary(Some(excerpt_ids[1]))), // footer, path, header
(4, BlockId::ExcerptHeader(excerpt_ids[1])), (9..12, BlockId::ExcerptBoundary(Some(excerpt_ids[2]))), // footer, path, header
(7, BlockId::ExcerptFooter(excerpt_ids[1])), (14..15, BlockId::ExcerptBoundary(None)), // footer
(8, BlockId::ExcerptHeader(excerpt_ids[2])),
(11, BlockId::ExcerptFooter(excerpt_ids[2]))
] ]
); );
} }
@ -2283,13 +2238,10 @@ mod tests {
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
enum ExpectedBlock { enum ExpectedBlock {
ExcerptHeader { ExcerptBoundary {
height: u32, height: u32,
starts_new_buffer: bool, starts_new_buffer: bool,
}, is_last: bool,
ExcerptFooter {
height: u32,
disposition: BlockDisposition,
}, },
Custom { Custom {
disposition: BlockDisposition, disposition: BlockDisposition,
@ -2303,8 +2255,7 @@ mod tests {
fn block_type(&self) -> BlockType { fn block_type(&self) -> BlockType {
match self { match self {
ExpectedBlock::Custom { id, .. } => BlockType::Custom(*id), ExpectedBlock::Custom { id, .. } => BlockType::Custom(*id),
ExpectedBlock::ExcerptHeader { .. } => BlockType::Header, ExpectedBlock::ExcerptBoundary { .. } => BlockType::ExcerptBoundary,
ExpectedBlock::ExcerptFooter { .. } => BlockType::Footer,
} }
} }
@ -2315,8 +2266,7 @@ mod tests {
fn priority(&self) -> usize { fn priority(&self) -> usize {
match self { match self {
ExpectedBlock::Custom { priority, .. } => *priority, ExpectedBlock::Custom { priority, .. } => *priority,
ExpectedBlock::ExcerptHeader { .. } => usize::MAX, ExpectedBlock::ExcerptBoundary { .. } => usize::MAX,
ExpectedBlock::ExcerptFooter { .. } => 0,
} }
} }
} }
@ -2324,17 +2274,21 @@ mod tests {
impl ExpectedBlock { impl ExpectedBlock {
fn height(&self) -> u32 { fn height(&self) -> u32 {
match self { match self {
ExpectedBlock::ExcerptHeader { height, .. } => *height, ExpectedBlock::ExcerptBoundary { height, .. } => *height,
ExpectedBlock::Custom { height, .. } => *height, ExpectedBlock::Custom { height, .. } => *height,
ExpectedBlock::ExcerptFooter { height, .. } => *height,
} }
} }
fn disposition(&self) -> BlockDisposition { fn disposition(&self) -> BlockDisposition {
match self { match self {
ExpectedBlock::ExcerptHeader { .. } => BlockDisposition::Above, ExpectedBlock::ExcerptBoundary { is_last, .. } => {
if *is_last {
BlockDisposition::Below
} else {
BlockDisposition::Above
}
}
ExpectedBlock::Custom { disposition, .. } => *disposition, ExpectedBlock::Custom { disposition, .. } => *disposition,
ExpectedBlock::ExcerptFooter { disposition, .. } => *disposition,
} }
} }
} }
@ -2348,21 +2302,15 @@ mod tests {
height: block.height, height: block.height,
priority: block.priority, priority: block.priority,
}, },
Block::ExcerptHeader { Block::ExcerptBoundary {
height, height,
starts_new_buffer, starts_new_buffer,
next_excerpt,
.. ..
} => ExpectedBlock::ExcerptHeader { } => ExpectedBlock::ExcerptBoundary {
height, height,
starts_new_buffer, starts_new_buffer,
}, is_last: next_excerpt.is_none(),
Block::ExcerptFooter {
height,
disposition,
..
} => ExpectedBlock::ExcerptFooter {
height,
disposition,
}, },
} }
} }
@ -2380,8 +2328,7 @@ mod tests {
fn as_custom(&self) -> Option<&CustomBlock> { fn as_custom(&self) -> Option<&CustomBlock> {
match self { match self {
Block::Custom(block) => Some(block), Block::Custom(block) => Some(block),
Block::ExcerptHeader { .. } => None, Block::ExcerptBoundary { .. } => None,
Block::ExcerptFooter { .. } => None,
} }
} }
} }

View file

@ -73,12 +73,12 @@ use git::blame::GitBlame;
use gpui::{ use gpui::{
div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement, div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement,
AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardEntry, AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardEntry,
ClipboardItem, Context, DispatchPhase, ElementId, EntityId, EventEmitter, FocusHandle, ClipboardItem, Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusOutEvent,
FocusOutEvent, FocusableView, FontId, FontWeight, HighlightStyle, Hsla, InteractiveText, FocusableView, FontId, FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext,
KeyContext, ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString,
SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle, UTF16Selection,
UTF16Selection, UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext,
VisualContext, WeakFocusHandle, WeakView, WindowContext, WeakFocusHandle, WeakView, WindowContext,
}; };
use highlight_matching_bracket::refresh_matching_bracket_highlights; use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_popover::{hide_hover, HoverState}; use hover_popover::{hide_hover, HoverState};
@ -171,7 +171,7 @@ use workspace::{Item as WorkspaceItem, OpenInTerminal, OpenTerminal, TabBarSetti
use crate::hover_links::find_url; use crate::hover_links::find_url;
use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState}; use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
pub const FILE_HEADER_HEIGHT: u32 = 1; pub const FILE_HEADER_HEIGHT: u32 = 2;
pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1; pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
pub const MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT: u32 = 1; pub const MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT: u32 = 1;
pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2; pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2;
@ -640,7 +640,6 @@ pub struct Editor {
tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
tasks_update_task: Option<Task<()>>, tasks_update_task: Option<Task<()>>,
previous_search_ranges: Option<Arc<[Range<Anchor>]>>, previous_search_ranges: Option<Arc<[Range<Anchor>]>>,
file_header_size: u32,
breadcrumb_header: Option<String>, breadcrumb_header: Option<String>,
focused_block: Option<FocusedBlock>, focused_block: Option<FocusedBlock>,
next_scroll_position: NextScrollCursorCenterTopBottom, next_scroll_position: NextScrollCursorCenterTopBottom,
@ -1846,7 +1845,6 @@ impl Editor {
}), }),
merge_adjacent: true, merge_adjacent: true,
}; };
let file_header_size = if show_excerpt_controls { 3 } else { 2 };
let display_map = cx.new_model(|cx| { let display_map = cx.new_model(|cx| {
DisplayMap::new( DisplayMap::new(
buffer.clone(), buffer.clone(),
@ -1854,7 +1852,7 @@ impl Editor {
font_size, font_size,
None, None,
show_excerpt_controls, show_excerpt_controls,
file_header_size, FILE_HEADER_HEIGHT,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT, MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT,
fold_placeholder, fold_placeholder,
@ -2038,7 +2036,6 @@ impl Editor {
.restore_unsaved_buffers, .restore_unsaved_buffers,
blame: None, blame: None,
blame_subscription: None, blame_subscription: None,
file_header_size,
tasks: Default::default(), tasks: Default::default(),
_subscriptions: vec![ _subscriptions: vec![
cx.observe(&buffer, Self::on_buffer_changed), cx.observe(&buffer, Self::on_buffer_changed),
@ -12808,7 +12805,7 @@ impl Editor {
} }
pub fn file_header_size(&self) -> u32 { pub fn file_header_size(&self) -> u32 {
self.file_header_size FILE_HEADER_HEIGHT
} }
pub fn revert( pub fn revert(
@ -14120,7 +14117,7 @@ pub fn diagnostic_block_renderer(
let multi_line_diagnostic = diagnostic.message.contains('\n'); let multi_line_diagnostic = diagnostic.message.contains('\n');
let buttons = |diagnostic: &Diagnostic, block_id: BlockId| { let buttons = |diagnostic: &Diagnostic| {
if multi_line_diagnostic { if multi_line_diagnostic {
v_flex() v_flex()
} else { } else {
@ -14128,7 +14125,7 @@ pub fn diagnostic_block_renderer(
} }
.when(allow_closing, |div| { .when(allow_closing, |div| {
div.children(diagnostic.is_primary.then(|| { div.children(diagnostic.is_primary.then(|| {
IconButton::new(("close-block", EntityId::from(block_id)), IconName::XCircle) IconButton::new("close-block", IconName::XCircle)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.size(ButtonSize::Compact) .size(ButtonSize::Compact)
.style(ButtonStyle::Transparent) .style(ButtonStyle::Transparent)
@ -14138,7 +14135,7 @@ pub fn diagnostic_block_renderer(
})) }))
}) })
.child( .child(
IconButton::new(("copy-block", EntityId::from(block_id)), IconName::Copy) IconButton::new("copy-block", IconName::Copy)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.size(ButtonSize::Compact) .size(ButtonSize::Compact)
.style(ButtonStyle::Transparent) .style(ButtonStyle::Transparent)
@ -14153,7 +14150,7 @@ pub fn diagnostic_block_renderer(
) )
}; };
let icon_size = buttons(&diagnostic, cx.block_id) let icon_size = buttons(&diagnostic)
.into_any_element() .into_any_element()
.layout_as_root(AvailableSpace::min_size(), cx); .layout_as_root(AvailableSpace::min_size(), cx);
@ -14170,7 +14167,7 @@ pub fn diagnostic_block_renderer(
.w(cx.anchor_x - cx.gutter_dimensions.width - icon_size.width) .w(cx.anchor_x - cx.gutter_dimensions.width - icon_size.width)
.flex_shrink(), .flex_shrink(),
) )
.child(buttons(&diagnostic, cx.block_id)) .child(buttons(&diagnostic))
.child(div().flex().flex_shrink_0().child( .child(div().flex().flex_shrink_0().child(
StyledText::new(text_without_backticks.clone()).with_highlights( StyledText::new(text_without_backticks.clone()).with_highlights(
&text_style, &text_style,

View file

@ -21,7 +21,8 @@ use crate::{
EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GutterDimensions, HalfPageDown, EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GutterDimensions, HalfPageDown,
HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, LineDown, LineUp, OpenExcerpts, PageDown, HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, LineDown, LineUp, OpenExcerpts, PageDown,
PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection, SoftWrap, ToPoint, PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection, SoftWrap, ToPoint,
CURSORS_VISIBLE_FOR, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
}; };
use client::ParticipantIndex; use client::ParticipantIndex;
use collections::{BTreeMap, HashMap}; use collections::{BTreeMap, HashMap};
@ -31,7 +32,7 @@ use gpui::{
anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg, anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem, transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity,
EntityId, FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length, FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,
@ -46,7 +47,7 @@ use language::{
ChunkRendererContext, ChunkRendererContext,
}; };
use lsp::DiagnosticSeverity; use lsp::DiagnosticSeverity;
use multi_buffer::{Anchor, MultiBufferPoint, MultiBufferRow}; use multi_buffer::{Anchor, ExcerptId, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow};
use project::{ use project::{
project_settings::{GitGutterSetting, ProjectSettings}, project_settings::{GitGutterSetting, ProjectSettings},
ProjectPath, ProjectPath,
@ -1632,7 +1633,7 @@ impl EditorElement {
let mut block_offset = 0; let mut block_offset = 0;
let mut found_excerpt_header = false; let mut found_excerpt_header = false;
for (_, block) in snapshot.blocks_in_range(prev_line..row_range.start) { for (_, block) in snapshot.blocks_in_range(prev_line..row_range.start) {
if matches!(block, Block::ExcerptHeader { .. }) { if matches!(block, Block::ExcerptBoundary { .. }) {
found_excerpt_header = true; found_excerpt_header = true;
break; break;
} }
@ -1649,7 +1650,7 @@ impl EditorElement {
let mut block_height = 0; let mut block_height = 0;
let mut found_excerpt_header = false; let mut found_excerpt_header = false;
for (_, block) in snapshot.blocks_in_range(row_range.end..cons_line) { for (_, block) in snapshot.blocks_in_range(row_range.end..cons_line) {
if matches!(block, Block::ExcerptHeader { .. }) { if matches!(block, Block::ExcerptBoundary { .. }) {
found_excerpt_header = true; found_excerpt_header = true;
} }
block_height += block.height(); block_height += block.height();
@ -2100,23 +2101,14 @@ impl EditorElement {
.into_any_element() .into_any_element()
} }
Block::ExcerptHeader { Block::ExcerptBoundary {
buffer, prev_excerpt,
range, next_excerpt,
show_excerpt_controls,
starts_new_buffer, starts_new_buffer,
height, height,
id,
show_excerpt_controls,
.. ..
} => { } => {
let include_root = self
.editor
.read(cx)
.project
.as_ref()
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
.unwrap_or_default();
#[derive(Clone)] #[derive(Clone)]
struct JumpData { struct JumpData {
position: Point, position: Point,
@ -2125,6 +2117,34 @@ impl EditorElement {
line_offset_from_top: u32, line_offset_from_top: u32,
} }
let icon_offset = gutter_dimensions.width
- (gutter_dimensions.left_padding + gutter_dimensions.margin);
let header_padding = px(6.0);
let mut result = v_flex().id(block_id).w_full();
if let Some(prev_excerpt) = prev_excerpt {
if *show_excerpt_controls {
result = result.child(
h_flex()
.w(icon_offset)
.h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
.flex_none()
.justify_end()
.child(self.render_expand_excerpt_button(
prev_excerpt.id,
ExpandExcerptDirection::Down,
IconName::ArrowDownFromLine,
cx,
)),
);
}
}
if let Some(next_excerpt) = next_excerpt {
let buffer = &next_excerpt.buffer;
let range = &next_excerpt.range;
let jump_data = project::File::from_dyn(buffer.file()).map(|file| { let jump_data = project::File::from_dyn(buffer.file()).map(|file| {
let jump_path = ProjectPath { let jump_path = ProjectPath {
worktree_id: file.worktree_id(cx), worktree_id: file.worktree_id(cx),
@ -2160,33 +2180,35 @@ impl EditorElement {
} }
}); });
let icon_offset = gutter_dimensions.width if *starts_new_buffer {
- (gutter_dimensions.left_padding + gutter_dimensions.margin); let include_root = self
.editor
let element = if *starts_new_buffer { .read(cx)
.project
.as_ref()
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
.unwrap_or_default();
let path = buffer.resolve_file_path(cx, include_root); let path = buffer.resolve_file_path(cx, include_root);
let mut filename = None; let filename = path
let mut parent_path = None; .as_ref()
// Can't use .and_then() because `.file_name()` and `.parent()` return references :( .and_then(|path| Some(path.file_name()?.to_string_lossy().to_string()));
if let Some(path) = path { let parent_path = path.as_ref().and_then(|path| {
filename = path.file_name().map(|f| f.to_string_lossy().to_string()); Some(path.parent()?.to_string_lossy().to_string() + "/")
parent_path = path });
.parent()
.map(|p| SharedString::from(p.to_string_lossy().to_string() + "/"));
}
let header_padding = px(6.0); result = result.child(
div()
v_flex()
.id(("path excerpt header", EntityId::from(block_id)))
.w_full()
.px(header_padding) .px(header_padding)
.pt(header_padding) .pt(header_padding)
.w_full()
.h(FILE_HEADER_HEIGHT as f32 * cx.line_height())
.child( .child(
h_flex() h_flex()
.flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
.id("path header block") .id("path header block")
.h(2. * cx.line_height()) .size_full()
.flex_basis(Length::Definite(DefiniteLength::Fraction(
0.667,
)))
.px(gpui::px(12.)) .px(gpui::px(12.))
.rounded_md() .rounded_md()
.shadow_md() .shadow_md()
@ -2205,19 +2227,21 @@ impl EditorElement {
.unwrap_or_else(|| "untitled".into()), .unwrap_or_else(|| "untitled".into()),
) )
.when_some(parent_path, |then, path| { .when_some(parent_path, |then, path| {
then.child( then.child(div().child(path).text_color(
div() cx.theme().colors().text_muted,
.child(path) ))
.text_color(cx.theme().colors().text_muted),
)
}), }),
), ),
) )
.when_some(jump_data.clone(), |el, jump_data| { .when_some(jump_data, |el, jump_data| {
el.child(Icon::new(IconName::ArrowUpRight)) el.child(Icon::new(IconName::ArrowUpRight))
.cursor_pointer() .cursor_pointer()
.tooltip(|cx| { .tooltip(|cx| {
Tooltip::for_action("Jump to File", &OpenExcerpts, cx) Tooltip::for_action(
"Jump to File",
&OpenExcerpts,
cx,
)
}) })
.on_mouse_down(MouseButton::Left, |_, cx| { .on_mouse_down(MouseButton::Left, |_, cx| {
cx.stop_propagation() cx.stop_propagation()
@ -2234,174 +2258,43 @@ impl EditorElement {
} }
})) }))
}), }),
) ),
.children(show_excerpt_controls.then(|| { );
if *show_excerpt_controls {
result = result.child(
h_flex() h_flex()
.flex_basis(Length::Definite(DefiniteLength::Fraction(0.333))) .w(icon_offset)
.h(1. * cx.line_height()) .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
.pt_1()
.justify_end()
.flex_none() .flex_none()
.w(icon_offset - header_padding) .justify_end()
.child( .child(self.render_expand_excerpt_button(
ButtonLike::new("expand-icon") next_excerpt.id,
.style(ButtonStyle::Transparent) ExpandExcerptDirection::Up,
.child( IconName::ArrowUpFromLine,
svg()
.path(IconName::ArrowUpFromLine.path())
.size(IconSize::XSmall.rems())
.text_color(cx.theme().colors().editor_line_number)
.group("")
.hover(|style| {
style.text_color(
cx.theme()
.colors()
.editor_active_line_number,
)
}),
)
.on_click(cx.listener_for(&self.editor, {
let id = *id;
move |editor, _, cx| {
editor.expand_excerpt(
id,
multi_buffer::ExpandExcerptDirection::Up,
cx, cx,
)),
); );
} }
}))
.tooltip({
move |cx| {
Tooltip::for_action(
"Expand Excerpt",
&ExpandExcerpts { lines: 0 },
cx,
)
}
}),
)
}))
} else { } else {
v_flex() result = result.child(
.id(("excerpt header", EntityId::from(block_id))) h_flex()
.w_full() .id("excerpt header block")
.h(snapshot.excerpt_header_height() as f32 * cx.line_height()) .group("excerpt-jump-action")
.child(
div()
.flex()
.v_flex()
.justify_start() .justify_start()
.id("jump to collapsed context") .w_full()
.w(relative(1.0)) .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
.h_full() .relative()
.child( .child(
div() div()
.h_px() .top(px(0.))
.absolute()
.w_full() .w_full()
.h_px()
.bg(cx.theme().colors().border_variant) .bg(cx.theme().colors().border_variant)
.group_hover("excerpt-jump-action", |style| { .group_hover("excerpt-jump-action", |style| {
style.bg(cx.theme().colors().border) style.bg(cx.theme().colors().border)
}), }),
),
) )
.child(
h_flex()
.justify_end()
.flex_none()
.w(icon_offset)
.h_full()
.child(
show_excerpt_controls
.then(|| {
ButtonLike::new("expand-icon")
.style(ButtonStyle::Transparent)
.child(
svg()
.path(IconName::ArrowUpFromLine.path())
.size(IconSize::XSmall.rems())
.text_color(
cx.theme().colors().editor_line_number,
)
.group("")
.hover(|style| {
style.text_color(
cx.theme()
.colors()
.editor_active_line_number,
)
}),
)
.on_click(cx.listener_for(&self.editor, {
let id = *id;
move |editor, _, cx| {
editor.expand_excerpt(
id,
multi_buffer::ExpandExcerptDirection::Up,
cx,
);
}
}))
.tooltip({
move |cx| {
Tooltip::for_action(
"Expand Excerpt",
&ExpandExcerpts { lines: 0 },
cx,
)
}
})
})
.unwrap_or_else(|| {
ButtonLike::new("jump-icon")
.style(ButtonStyle::Transparent)
.child(
svg()
.path(IconName::ArrowUpRight.path())
.size(IconSize::XSmall.rems())
.text_color(
cx.theme().colors().border_variant,
)
.group("excerpt-jump-action")
.group_hover(
"excerpt-jump-action",
|style| {
style.text_color(
cx.theme().colors().border,
)
},
),
)
.when_some(jump_data.clone(), |this, jump_data| {
this.on_click(cx.listener_for(&self.editor, {
let path = jump_data.path.clone();
move |editor, _, cx| {
cx.stop_propagation();
editor.jump(
path.clone(),
jump_data.position,
jump_data.anchor,
jump_data.line_offset_from_top,
cx,
);
}
}))
.tooltip(move |cx| {
Tooltip::for_action(
format!(
"Jump to {}:L{}",
jump_data.path.path.display(),
jump_data.position.row + 1
),
&OpenExcerpts,
cx,
)
})
})
}),
),
)
.group("excerpt-jump-action")
.cursor_pointer() .cursor_pointer()
.when_some(jump_data.clone(), |this, jump_data| { .when_some(jump_data.clone(), |this, jump_data| {
this.on_click(cx.listener_for(&self.editor, { this.on_click(cx.listener_for(&self.editor, {
@ -2430,59 +2323,46 @@ impl EditorElement {
) )
}) })
}) })
};
element.into_any()
}
Block::ExcerptFooter { id, .. } => {
let element = v_flex()
.id(("excerpt footer", EntityId::from(block_id)))
.w_full()
.h(snapshot.excerpt_footer_height() as f32 * cx.line_height())
.child( .child(
h_flex() h_flex()
.justify_end() .w(icon_offset)
.h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32
* cx.line_height())
.flex_none() .flex_none()
.w(gutter_dimensions.width .justify_end()
- (gutter_dimensions.left_padding + gutter_dimensions.margin)) .child(if *show_excerpt_controls {
.h_full() self.render_expand_excerpt_button(
.child( next_excerpt.id,
ButtonLike::new("expand-icon") ExpandExcerptDirection::Up,
IconName::ArrowUpFromLine,
cx,
)
} else {
ButtonLike::new("jump-icon")
.style(ButtonStyle::Transparent) .style(ButtonStyle::Transparent)
.child( .child(
svg() svg()
.path(IconName::ArrowDownFromLine.path()) .path(IconName::ArrowUpRight.path())
.size(IconSize::XSmall.rems()) .size(IconSize::XSmall.rems())
.text_color(cx.theme().colors().editor_line_number) .text_color(
.group("") cx.theme().colors().border_variant,
.hover(|style| { )
.group_hover(
"excerpt-jump-action",
|style| {
style.text_color( style.text_color(
cx.theme().colors().editor_active_line_number, cx.theme().colors().border,
) )
}), },
),
) )
.on_click(cx.listener_for(&self.editor, {
let id = *id;
move |editor, _, cx| {
editor.expand_excerpt(
id,
multi_buffer::ExpandExcerptDirection::Down,
cx,
);
}
}))
.tooltip({
move |cx| {
Tooltip::for_action(
"Expand Excerpt",
&ExpandExcerpts { lines: 0 },
cx,
)
}
}), }),
), ),
); );
element.into_any() }
}
result.into_any()
} }
}; };
@ -2509,6 +2389,33 @@ impl EditorElement {
(element, final_size) (element, final_size)
} }
fn render_expand_excerpt_button(
&self,
excerpt_id: ExcerptId,
direction: ExpandExcerptDirection,
icon: IconName,
cx: &mut WindowContext,
) -> ButtonLike {
ButtonLike::new("expand-icon")
.style(ButtonStyle::Transparent)
.child(
svg()
.path(icon.path())
.size(IconSize::XSmall.rems())
.text_color(cx.theme().colors().editor_line_number)
.group("")
.hover(|style| style.text_color(cx.theme().colors().editor_active_line_number)),
)
.on_click(cx.listener_for(&self.editor, {
move |editor, _, cx| {
editor.expand_excerpt(excerpt_id, direction, cx);
}
}))
.tooltip({
move |cx| Tooltip::for_action("Expand Excerpt", &ExpandExcerpts { lines: 0 }, cx)
})
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn render_blocks( fn render_blocks(
&self, &self,
@ -3367,7 +3274,7 @@ impl EditorElement {
let end_row_in_current_excerpt = snapshot let end_row_in_current_excerpt = snapshot
.blocks_in_range(start_row..end_row) .blocks_in_range(start_row..end_row)
.find_map(|(start_row, block)| { .find_map(|(start_row, block)| {
if matches!(block, Block::ExcerptHeader { .. }) { if matches!(block, Block::ExcerptBoundary { .. }) {
Some(start_row) Some(start_row)
} else { } else {
None None

View file

@ -952,7 +952,7 @@ mod tests {
px(14.0), px(14.0),
None, None,
true, true,
2, 0,
2, 2,
0, 0,
FoldPlaceholder::test(), FoldPlaceholder::test(),

View file

@ -189,6 +189,7 @@ pub struct MultiBufferSnapshot {
show_headers: bool, show_headers: bool,
} }
#[derive(Clone)]
pub struct ExcerptInfo { pub struct ExcerptInfo {
pub id: ExcerptId, pub id: ExcerptId,
pub buffer: BufferSnapshot, pub buffer: BufferSnapshot,
@ -201,6 +202,7 @@ impl std::fmt::Debug for ExcerptInfo {
f.debug_struct(type_name::<Self>()) f.debug_struct(type_name::<Self>())
.field("id", &self.id) .field("id", &self.id)
.field("buffer_id", &self.buffer_id) .field("buffer_id", &self.buffer_id)
.field("path", &self.buffer.file().map(|f| f.path()))
.field("range", &self.range) .field("range", &self.range)
.finish() .finish()
} }

View file

@ -17,8 +17,7 @@ use editor::{
use futures::io::BufReader; use futures::io::BufReader;
use futures::{AsyncBufReadExt as _, FutureExt as _, StreamExt as _}; use futures::{AsyncBufReadExt as _, FutureExt as _, StreamExt as _};
use gpui::{ use gpui::{
div, prelude::*, EntityId, EventEmitter, Model, Render, Subscription, Task, View, ViewContext, div, prelude::*, EventEmitter, Model, Render, Subscription, Task, View, ViewContext, WeakView,
WeakView,
}; };
use language::Point; use language::Point;
use project::Fs; use project::Fs;
@ -149,10 +148,7 @@ impl EditorBlock {
.w(text_line_height) .w(text_line_height)
.h(text_line_height) .h(text_line_height)
.child( .child(
IconButton::new( IconButton::new("close_output_area", IconName::Close)
("close_output_area", EntityId::from(cx.block_id)),
IconName::Close,
)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.size(ButtonSize::Compact) .size(ButtonSize::Compact)
@ -166,6 +162,7 @@ impl EditorBlock {
); );
div() div()
.id(cx.block_id)
.flex() .flex()
.items_start() .items_start()
.min_h(text_line_height) .min_h(text_line_height)