From bff7ebcd646cc377508e02b863ae773f6b7b1bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 16 Oct 2026 14:01:47 -0400 Subject: [PATCH] Documents: an open block keeps its look The open field sits in the closed block's frame at its size, line height and weight; headings keep their step. Code and math keep their panel. Typewriter centering also keys on the ends' room, so a block opened at the same height still centers. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/documents/document/look.rs | 6 +- src/documents/document/mod.rs | 2 +- src/documents/document/tests/mod.rs | 1 + src/documents/document/tests/open_look.rs | 73 ++++++++++++++++++++++ src/documents/document/visual.rs | 75 +++++++++++++++++------ src/documents/render.rs | 24 +++++--- src/editor/tests/wide.rs | 3 +- 7 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 src/documents/document/tests/open_look.rs diff --git a/src/documents/document/look.rs b/src/documents/document/look.rs index b049fd2..6aafbd5 100644 --- a/src/documents/document/look.rs +++ b/src/documents/document/look.rs @@ -90,9 +90,9 @@ impl MarkdownDocument { block.size.height -= self.room; } let view = self.list.viewport_bounds(); - // A new layout or a resize moves it too. - let heights = - [block.size.height, view.size.height].map(|height| f32::from(height).round() as u64); + // A new layout, a resize or the ends' room moves it. + let heights = [block.size.height, view.size.height, self.room] + .map(|height| f32::from(height).round() as u64); let moved = (ix, self.edits, self.typed, heights); if self.centered == Some(moved) { return; diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index b81700e..aef4af6 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -117,7 +117,7 @@ pub struct MarkdownDocument { /// Typewriter room past each end of the list. room: Pixels, /// The block, text, typing and heights last centered. - centered: Option<(usize, u64, u64, [u64; 2])>, + centered: Option<(usize, u64, u64, [u64; 3])>, focus: FocusHandle, /// A secondary press took focus. pointing: bool, diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index 531ffe9..fdd7b63 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -374,6 +374,7 @@ mod history; mod lend; mod links; mod look; +mod open_look; mod place; mod point; mod presses; diff --git a/src/documents/document/tests/open_look.rs b/src/documents/document/tests/open_look.rs new file mode 100644 index 0000000..2b71f2c --- /dev/null +++ b/src/documents/document/tests/open_look.rs @@ -0,0 +1,73 @@ +//! An open block keeps its place and size (R7.5). + +use gpui::{Bounds, Modifiers, Pixels, TestAppContext, VisualTestContext, rems}; + +use super::{open, press}; +use crate::documents::MarkdownMode; + +/// Block 0's bounds closed, then open. +fn closed_then_open<'a>( + source: &str, + cx: &'a mut TestAppContext, +) -> (Bounds, Bounds, &'a mut VisualTestContext) { + let (_, cx) = open(source, MarkdownMode::Visual, cx); + let closed = cx.debug_bounds("markdown-block-0").expect("drawn"); + press(0, Modifiers::none(), cx); + let open = cx.debug_bounds("markdown-open").expect("the block opens"); + (closed, open, cx) +} + +/// Within a pixel: a field rounds its line height. +fn near(a: Pixels, b: Pixels) -> bool { + (a - b).abs() < gpui::px(1.0) +} + +#[gpui::test] +fn an_open_paragraph_keeps_its_place_and_height(cx: &mut TestAppContext) { + let (closed, open, _) = closed_then_open("Plain words here.\n", cx); + assert!( + near(closed.top(), open.top()) && near(closed.left(), open.left()), + "{closed:?} {open:?}" + ); + assert!( + near(closed.size.height, open.size.height), + "{closed:?} {open:?}" + ); +} + +#[gpui::test] +fn an_open_heading_keeps_its_size(cx: &mut TestAppContext) { + let (closed, open, _) = closed_then_open("## Plain heading\n", cx); + assert!( + near(closed.top(), open.top()) && near(closed.left(), open.left()), + "{closed:?} {open:?}" + ); + assert!( + near(closed.size.height, open.size.height), + "{closed:?} {open:?}" + ); +} + +#[gpui::test] +fn an_open_code_block_keeps_its_panel(cx: &mut TestAppContext) { + let (closed, open, cx) = closed_then_open("```\nlet x = 1;\n```\n", cx); + assert!( + near(closed.top(), open.top()) && near(closed.left(), open.left()), + "{closed:?} {open:?}" + ); + // The panel sits inside the frame's border and padding. + let (x, y) = cx.update(|window, _| { + let rem = window.rem_size(); + (rems(0.75).to_pixels(rem), rems(0.25).to_pixels(rem)) + }); + let panel = cx.debug_bounds("markdown-open-text").expect("drawn"); + let border = gpui::px(1.0); + assert!( + near(panel.left(), closed.left() + border + x), + "{panel:?} {closed:?}" + ); + assert!( + near(panel.top(), closed.top() + border + y), + "{panel:?} {closed:?}" + ); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 9d2b99d..4ac8f0b 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -2,14 +2,14 @@ use gpui::{ AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement, IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render, StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list, - prelude::FluentBuilder as _, + prelude::FluentBuilder as _, relative, }; -use super::{MarkdownDocument, edit::Landing}; +use super::{MarkdownDocument, edit::Landing, parse::Block}; use crate::{ documents::{ - MarkdownMode, - render::{Hooks, OpenCell, Pointed, draw_nodes, point_lines}, + MarkdownMode, TargetBlock, + render::{HEAVY, Hooks, OpenCell, Pointed, draw_nodes, heading_step, point_lines}, }, forms::{self, Down, Up}, primitives::FocusRing as _, @@ -72,22 +72,9 @@ impl MarkdownDocument { // Focus fades all but the reader's block. let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx); let open = self.open.as_ref(); - let point = Self::point_in_open(cx); - let field = |open: &super::Open| { - div() - .debug_selector(|| "markdown-open".to_string()) - .on_mouse_down(MouseButton::Right, point) - .text_size(body) - .px_3() - .py_2() - .rounded(radius) - .bg(colors.sunken) - .child(open.input.clone()) - .into_any_element() - }; let Some(block) = self.parsed.blocks.get(ix) else { return match (open, editing) { - (Some(open), _) if open.append => field(open), + (Some(open), _) if open.append => self.field(open, None, cx), (_, true) => self .pressable(div().id("more").min_h_8(), None, cx) .into_any_element(), @@ -95,7 +82,7 @@ impl MarkdownDocument { }; }; if let Some(open) = open.filter(|open| open.cell.is_none() && open.range == block.range) { - return field(open); + return self.field(open, Some(block), cx); } // A cell open in this block, typed in place. let cell = open.and_then(|open| { @@ -168,6 +155,56 @@ impl MarkdownDocument { } } + /// The open block in its closed frame, size and weight. + fn field( + &self, + open: &super::Open, + block: Option<&Block>, + cx: &mut Context, + ) -> AnyElement { + let point = Self::point_in_open(cx); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let body = theme.text_size(TextSize::Base) * self.look.scale; + let text = div() + .debug_selector(|| "markdown-open-text".to_string()) + .text_size(body) + .line_height(body * 1.6) + .text_color(colors.fg); + let text = match block.map(|block| (block.kind, block.nodes.is_some())) { + Some((TargetBlock::Heading(level), true)) => text + .pt_2() + .text_size(body * heading_step(level)) + .line_height(relative(1.3)) + .font_weight(HEAVY) + .when(level == 6, |text| text.text_color(colors.fg_muted)), + // Code and math keep their panel. + Some((kind @ (TargetBlock::Code | TargetBlock::Math), true)) => text + .p_3() + .rounded(theme.radius(Radius::Md)) + .bg(colors.sunken) + .when(kind == TargetBlock::Code, |text| { + text.font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Sm) * self.look.scale) + }), + // Written as is: mono and muted. + Some((_, false)) => text + .font_family(theme.mono_family.clone()) + .line_height(gpui::phi()) + .text_color(colors.fg_muted), + _ => text, + }; + div() + .debug_selector(|| "markdown-open".to_string()) + .on_mouse_down(MouseButton::Right, point) + .px_3() + .py_1() + .border_1() + .border_color(gpui::transparent_black()) + .child(text.child(open.input.clone())) + .into_any_element() + } + /// A right press in the open field points there. fn point_in_open( cx: &mut Context, diff --git a/src/documents/render.rs b/src/documents/render.rs index 841e5c5..681ede1 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -102,7 +102,18 @@ pub(crate) struct Hooks<'a> { } /// Headings and strong text share one weight. -const HEAVY: FontWeight = FontWeight::BOLD; +pub(crate) const HEAVY: FontWeight = FontWeight::BOLD; + +/// Typora's heading steps over the body. +pub(crate) fn heading_step(level: u8) -> f32 { + match level { + 1 => 2.25, + 2 => 1.75, + 3 => 1.5, + 4 => 1.25, + _ => 1.0, + } +} /// How one inline style draws. fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle { @@ -234,15 +245,8 @@ impl Draw<'_> { .child(self.inline(inline, cx)) .into_any_element(), Node::Heading(level, inline) => { - // Typora's steps over the body; every level bold. - let step = match level { - 1 => 2.25, - 2 => 1.75, - 3 => 1.5, - 4 => 1.25, - _ => 1.0, - }; - let size = theme.text_size(self.body) * step * self.scale; + // Every level bold. + let size = theme.text_size(self.body) * heading_step(*level) * self.scale; let ordinal = self.headings.get(); self.headings.set(ordinal + 1); let heading = div() diff --git a/src/editor/tests/wide.rs b/src/editor/tests/wide.rs index 22ada8e..e9a8e0f 100644 --- a/src/editor/tests/wide.rs +++ b/src/editor/tests/wide.rs @@ -52,7 +52,8 @@ fn an_input_method_edit_measures_before_the_next_draw(cx: &mut TestAppContext) { let (editor, cx) = editor("(a)", cx); cx.run_until_parked(); editor.update_in(cx, |editor, window, cx| { - editor.select([0..0], cx); + let start = 0..0; + editor.select([start], cx); editor.replace_and_mark_text_in_range(None, "测", None, window, cx); let text = editor.row_text(0).to_string(); for (range, _) in editor.styles(0, cx) {