diff --git a/src/documents/document/look.rs b/src/documents/document/look.rs index 0c79cbf..b049fd2 100644 --- a/src/documents/document/look.rs +++ b/src/documents/document/look.rs @@ -58,22 +58,45 @@ impl MarkdownDocument { } } + /// Half a view of room at each end, for typewriter. + pub(super) fn make_room(&mut self) { + let room = match self.look.typewriter && self.mode == MarkdownMode::Visual { + true => self.list.viewport_bounds().size.height / 2.0, + false => gpui::Pixels::ZERO, + }; + if room != self.room { + self.room = room; + let last = self.parsed.blocks.len(); + self.list.remeasure_items(0..1); + self.list.remeasure_items(last..last + 1); + } + } + /// The current block mid-view once it or its text moves. pub(super) fn center_current(&mut self, cx: &gpui::App) { if !self.look.typewriter || self.mode != MarkdownMode::Visual { return; } let ix = self.current_block(cx); - let Some(block) = self.list.bounds_for_item(ix) else { + let Some(mut block) = self.list.bounds_for_item(ix) else { return; }; - // A new layout moves it too: its height counts. - let height = f32::from(block.size.height).round() as u64; - let moved = (ix, self.edits, self.typed, height); + // The ends' room is no block. + if ix == 0 { + block.origin.y += self.room; + block.size.height -= self.room; + } + if ix == self.parsed.blocks.len() { + 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); + let moved = (ix, self.edits, self.typed, heights); if self.centered == Some(moved) { return; } - let view = self.list.viewport_bounds(); self.list.scroll_by(block.center().y - view.center().y); self.centered = Some(moved); } diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 5265670..0f086ae 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -16,7 +16,7 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, - ListState, Subscription, Task, Window, + ListState, Pixels, Subscription, Task, Window, }; pub use attach::Attachment; @@ -88,8 +88,10 @@ pub struct MarkdownDocument { look: DocumentLook, /// Typing in the open block, counted. typed: u64, - /// The block, text, typing and height last centered. - centered: Option<(usize, u64, u64, u64)>, + /// 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])>, focus: FocusHandle, on_link: Option, _subscriptions: Vec, @@ -146,6 +148,7 @@ impl MarkdownDocument { look: DocumentLook::default(), typed: 0, centered: None, + room: Pixels::ZERO, focus, on_link: None, _subscriptions: subscriptions, diff --git a/src/documents/document/tests/look.rs b/src/documents/document/tests/look.rs index 8e01a70..af8aa96 100644 --- a/src/documents/document/tests/look.rs +++ b/src/documents/document/tests/look.rs @@ -106,3 +106,113 @@ fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) { 3 ); } + +fn typewriter() -> DocumentLook { + DocumentLook { + typewriter: true, + ..DocumentLook::default() + } +} + +/// How far block `ix` sits from mid-view. +fn off_middle( + document: &gpui::Entity, + ix: usize, + cx: &mut gpui::VisualTestContext, +) -> gpui::Pixels { + document.read_with(cx, |document, _| { + let mut block = document.list.bounds_for_item(ix).expect("laid out"); + if ix == 0 { + block.origin.y += document.room; + block.size.height -= document.room; + } + if ix == document.parsed.blocks.len() { + block.size.height -= document.room; + } + (block.center().y - document.list.viewport_bounds().center().y).abs() + }) +} + +#[gpui::test] +fn an_open_block_takes_the_scale(cx: &mut TestAppContext) { + let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx); + document.update_in(cx, |document, window, cx| { + document.open(Some(0), false, window, cx) + }); + frames(cx); + let plain = document + .read_with(cx, |document, _| document.list.bounds_for_item(0)) + .expect("laid out"); + let large = DocumentLook { + scale: 2.0, + ..DocumentLook::default() + }; + document.update(cx, |document, cx| document.set_look(large, cx)); + frames(cx); + let scaled = document + .read_with(cx, |document, _| document.list.bounds_for_item(0)) + .expect("laid out"); + assert!( + scaled.size.height > plain.size.height * 1.5, + "{plain:?} to {scaled:?}" + ); +} + +#[gpui::test] +fn a_resize_centers_the_block_again(cx: &mut TestAppContext) { + let (document, cx) = open(&long(), MarkdownMode::Visual, cx); + document.update_in(cx, |document, window, cx| { + document.set_look(typewriter(), cx); + document.open(Some(40), false, window, cx); + document.list.scroll_to_reveal_item(40); + }); + frames(cx); + cx.simulate_resize(gpui::size(px(1000.0), px(400.0))); + frames(cx); + assert!(off_middle(&document, 40, cx) < px(4.0)); +} + +#[gpui::test] +fn the_first_and_last_blocks_center(cx: &mut TestAppContext) { + let (document, cx) = open(&long(), MarkdownMode::Visual, cx); + document.update_in(cx, |document, window, cx| { + document.set_look(typewriter(), cx); + document.open(Some(0), false, window, cx); + }); + frames(cx); + let first = off_middle(&document, 0, cx); + assert!(first < px(4.0), "the first is {first:?} off"); + document.update_in(cx, |document, window, cx| { + document.open(Some(59), false, window, cx); + document.list.scroll_to_reveal_item(59); + }); + frames(cx); + assert!(off_middle(&document, 59, cx) < px(4.0), "the last"); +} + +#[gpui::test] +fn the_last_source_line_centers_and_a_resize_follows(cx: &mut TestAppContext) { + let (document, cx) = open(&long(), MarkdownMode::Source, cx); + document.update(cx, |document, cx| { + document.set_look(typewriter(), cx); + document.set_caret(119, 0, cx); + }); + frames(cx); + let top = document.read_with(cx, |document, cx| document.top_line(cx)); + assert!( + top > 80, + "top {top}: the last line is mid-view, not at the bottom" + ); + cx.simulate_resize(gpui::size(px(1000.0), px(400.0))); + frames(cx); + let resized = document.read_with(cx, |document, cx| document.top_line(cx)); + let editor = document.read_with(cx, |document, _| document.editor().clone()); + let (line, height) = + editor.read_with(cx, |editor, _| (editor.metrics.line, editor.metrics.height)); + let half = (height / line / 2.0).floor() as usize; + let middle = 119 - half; + assert!( + resized.abs_diff(middle) <= 1, + "top {top} to {resized}; mid-view is {middle}" + ); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 328e922..7cac6e7 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -42,8 +42,19 @@ pub(crate) fn bind_keys(cx: &mut App) { } impl MarkdownDocument { - /// List item `ix`: a block, or the end. - fn item(&mut self, ix: usize, _: &mut Window, cx: &mut Context) -> AnyElement { + /// List item `ix`, with typewriter room at the ends. + fn item(&mut self, ix: usize, window: &mut Window, cx: &mut Context) -> AnyElement { + let (first, last) = (ix == 0, ix == self.parsed.blocks.len()); + let room = self.room; + div() + .when(first, |item| item.pt(room)) + .when(last, |item| item.pb(room)) + .child(self.block_item(ix, window, cx)) + .into_any_element() + } + + /// A block, or the end. + fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context) -> AnyElement { let theme = cx.theme(); let colors = theme.colors.clone(); let (radius, body, mono) = ( @@ -57,6 +68,7 @@ impl MarkdownDocument { let open = self.open.as_ref(); let field = |open: &super::Open| { div() + .text_size(body) .px_3() .py_2() .rounded(radius) @@ -164,6 +176,8 @@ impl Render for MarkdownDocument { cx.emit(super::Attachment::Files(dropped.paths().to_vec())); })); self.center_current(cx); + // Typewriter: half a view past each end. + self.make_room(); let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full(); // The text column: its measure, font, centered. let column = |content: gpui::AnyElement, look: &super::DocumentLook| { diff --git a/src/editor/state.rs b/src/editor/state.rs index 9980e3c..5a97487 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -100,6 +100,8 @@ pub(crate) struct Metrics { pub top: Pixels, pub advance: Pixels, pub line: Pixels, + /// The view's height, under any typewriter room. + pub height: Pixels, } const BLINK: Duration = Duration::from_millis(530); @@ -122,6 +124,8 @@ pub struct CodeEditor { pub(crate) metrics: Metrics, /// The caret row typewriter scrolling last centered. pub(crate) centered: Option, + /// Typewriter room past each end, as last drawn. + pub(crate) room: Pixels, pub(crate) dragging: bool, pub(crate) caret_on: bool, /// Blink runs only while focused, or unseen cursors redraw forever. @@ -208,6 +212,7 @@ impl CodeEditor { composing: None, metrics: Metrics::default(), centered: None, + room: Pixels::ZERO, dragging: false, caret_on: true, focused: false, diff --git a/src/editor/view.rs b/src/editor/view.rs index 944056d..94b19c5 100644 --- a/src/editor/view.rs +++ b/src/editor/view.rs @@ -54,7 +54,8 @@ impl CodeEditor { /// The first line the list shows, after scrolling. pub(crate) fn top_line(&self) -> Option { - let scrolled = -self.scroll.0.borrow().base_handle.offset().y; + // Typewriter room above the first line is no line. + let scrolled = -self.scroll.0.borrow().base_handle.offset().y - self.room; let first = (scrolled / self.frame.line).floor().max(0.0) as usize; self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..] .iter() @@ -270,6 +271,13 @@ impl Render for CodeEditor { .scroll_to_item_strict(row, ScrollStrategy::Center); self.centered = Some(row); } + // Half a view of room: a resize keeps the center. + let height = self.metrics.height; + self.room = match self.options.typewriter { + true => height / 2.0, + false => gpui::px(0.0), + }; + let room = self.room; let sticky = if self.options.sticky { self.sticky(cx) } else { @@ -284,7 +292,9 @@ impl Render for CodeEditor { }), ) .track_scroll(&self.scroll) - .size_full(); + .size_full() + .pt(room) + .pb(room); let (top, focus, input) = (cx.entity(), self.focus.clone(), cx.entity()); let unlock = cx.entity(); let banner = self.options.read_only.then(|| { @@ -303,12 +313,22 @@ impl Render for CodeEditor { .relative() .flex_1() .h_full() + .min_h_0() .child(list) .children(sticky) .child( canvas( - move |bounds, _, cx| { - top.update(cx, |editor, _| editor.metrics.top = bounds.origin.y); + move |bounds, window, cx| { + let resized = top.update(cx, |editor, _| { + editor.metrics.top = bounds.origin.y; + let resized = editor.metrics.height != bounds.size.height; + editor.metrics.height = bounds.size.height; + resized + }); + // A new height centers the caret again. + if resized { + window.request_animation_frame(); + } }, |_, _, _, _| {}, )