documents and editor: typewriter room at both ends, open blocks scaled, a resize re-centers
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
508fee9d15
commit
f102ea0b6c
6 files changed
+189
-14
No files matched your search
@@ -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.
|
/// The current block mid-view once it or its text moves.
|
||||||
pub(super) fn center_current(&mut self, cx: &gpui::App) {
|
pub(super) fn center_current(&mut self, cx: &gpui::App) {
|
||||||
if !self.look.typewriter || self.mode != MarkdownMode::Visual {
|
if !self.look.typewriter || self.mode != MarkdownMode::Visual {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ix = self.current_block(cx);
|
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;
|
return;
|
||||||
};
|
};
|
||||||
// A new layout moves it too: its height counts.
|
// The ends' room is no block.
|
||||||
let height = f32::from(block.size.height).round() as u64;
|
if ix == 0 {
|
||||||
let moved = (ix, self.edits, self.typed, height);
|
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) {
|
if self.centered == Some(moved) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let view = self.list.viewport_bounds();
|
|
||||||
self.list.scroll_by(block.center().y - view.center().y);
|
self.list.scroll_by(block.center().y - view.center().y);
|
||||||
self.centered = Some(moved);
|
self.centered = Some(moved);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
|
|||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||||
ListState, Subscription, Task, Window,
|
ListState, Pixels, Subscription, Task, Window,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use attach::Attachment;
|
pub use attach::Attachment;
|
||||||
@@ -88,8 +88,10 @@ pub struct MarkdownDocument {
|
|||||||
look: DocumentLook,
|
look: DocumentLook,
|
||||||
/// Typing in the open block, counted.
|
/// Typing in the open block, counted.
|
||||||
typed: u64,
|
typed: u64,
|
||||||
/// The block, text, typing and height last centered.
|
/// Typewriter room past each end of the list.
|
||||||
centered: Option<(usize, u64, u64, u64)>,
|
room: Pixels,
|
||||||
|
/// The block, text, typing and heights last centered.
|
||||||
|
centered: Option<(usize, u64, u64, [u64; 2])>,
|
||||||
focus: FocusHandle,
|
focus: FocusHandle,
|
||||||
on_link: Option<OnLink>,
|
on_link: Option<OnLink>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
@@ -146,6 +148,7 @@ impl MarkdownDocument {
|
|||||||
look: DocumentLook::default(),
|
look: DocumentLook::default(),
|
||||||
typed: 0,
|
typed: 0,
|
||||||
centered: None,
|
centered: None,
|
||||||
|
room: Pixels::ZERO,
|
||||||
focus,
|
focus,
|
||||||
on_link: None,
|
on_link: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
|
|||||||
@@ -106,3 +106,113 @@ fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) {
|
|||||||
3
|
3
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn typewriter() -> DocumentLook {
|
||||||
|
DocumentLook {
|
||||||
|
typewriter: true,
|
||||||
|
..DocumentLook::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far block `ix` sits from mid-view.
|
||||||
|
fn off_middle(
|
||||||
|
document: &gpui::Entity<super::MarkdownDocument>,
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,8 +42,19 @@ pub(crate) fn bind_keys(cx: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MarkdownDocument {
|
impl MarkdownDocument {
|
||||||
/// List item `ix`: a block, or the end.
|
/// List item `ix`, with typewriter room at the ends.
|
||||||
fn item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
fn item(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) -> AnyElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = theme.colors.clone();
|
let colors = theme.colors.clone();
|
||||||
let (radius, body, mono) = (
|
let (radius, body, mono) = (
|
||||||
@@ -57,6 +68,7 @@ impl MarkdownDocument {
|
|||||||
let open = self.open.as_ref();
|
let open = self.open.as_ref();
|
||||||
let field = |open: &super::Open| {
|
let field = |open: &super::Open| {
|
||||||
div()
|
div()
|
||||||
|
.text_size(body)
|
||||||
.px_3()
|
.px_3()
|
||||||
.py_2()
|
.py_2()
|
||||||
.rounded(radius)
|
.rounded(radius)
|
||||||
@@ -164,6 +176,8 @@ impl Render for MarkdownDocument {
|
|||||||
cx.emit(super::Attachment::Files(dropped.paths().to_vec()));
|
cx.emit(super::Attachment::Files(dropped.paths().to_vec()));
|
||||||
}));
|
}));
|
||||||
self.center_current(cx);
|
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();
|
let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full();
|
||||||
// The text column: its measure, font, centered.
|
// The text column: its measure, font, centered.
|
||||||
let column = |content: gpui::AnyElement, look: &super::DocumentLook| {
|
let column = |content: gpui::AnyElement, look: &super::DocumentLook| {
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ pub(crate) struct Metrics {
|
|||||||
pub top: Pixels,
|
pub top: Pixels,
|
||||||
pub advance: Pixels,
|
pub advance: Pixels,
|
||||||
pub line: Pixels,
|
pub line: Pixels,
|
||||||
|
/// The view's height, under any typewriter room.
|
||||||
|
pub height: Pixels,
|
||||||
}
|
}
|
||||||
|
|
||||||
const BLINK: Duration = Duration::from_millis(530);
|
const BLINK: Duration = Duration::from_millis(530);
|
||||||
@@ -122,6 +124,8 @@ pub struct CodeEditor {
|
|||||||
pub(crate) metrics: Metrics,
|
pub(crate) metrics: Metrics,
|
||||||
/// The caret row typewriter scrolling last centered.
|
/// The caret row typewriter scrolling last centered.
|
||||||
pub(crate) centered: Option<usize>,
|
pub(crate) centered: Option<usize>,
|
||||||
|
/// Typewriter room past each end, as last drawn.
|
||||||
|
pub(crate) room: Pixels,
|
||||||
pub(crate) dragging: bool,
|
pub(crate) dragging: bool,
|
||||||
pub(crate) caret_on: bool,
|
pub(crate) caret_on: bool,
|
||||||
/// Blink runs only while focused, or unseen cursors redraw forever.
|
/// Blink runs only while focused, or unseen cursors redraw forever.
|
||||||
@@ -208,6 +212,7 @@ impl CodeEditor {
|
|||||||
composing: None,
|
composing: None,
|
||||||
metrics: Metrics::default(),
|
metrics: Metrics::default(),
|
||||||
centered: None,
|
centered: None,
|
||||||
|
room: Pixels::ZERO,
|
||||||
dragging: false,
|
dragging: false,
|
||||||
caret_on: true,
|
caret_on: true,
|
||||||
focused: false,
|
focused: false,
|
||||||
|
|||||||
+24
-4
@@ -54,7 +54,8 @@ impl CodeEditor {
|
|||||||
|
|
||||||
/// The first line the list shows, after scrolling.
|
/// The first line the list shows, after scrolling.
|
||||||
pub(crate) fn top_line(&self) -> Option<usize> {
|
pub(crate) fn top_line(&self) -> Option<usize> {
|
||||||
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;
|
let first = (scrolled / self.frame.line).floor().max(0.0) as usize;
|
||||||
self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..]
|
self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -270,6 +271,13 @@ impl Render for CodeEditor {
|
|||||||
.scroll_to_item_strict(row, ScrollStrategy::Center);
|
.scroll_to_item_strict(row, ScrollStrategy::Center);
|
||||||
self.centered = Some(row);
|
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 {
|
let sticky = if self.options.sticky {
|
||||||
self.sticky(cx)
|
self.sticky(cx)
|
||||||
} else {
|
} else {
|
||||||
@@ -284,7 +292,9 @@ impl Render for CodeEditor {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.track_scroll(&self.scroll)
|
.track_scroll(&self.scroll)
|
||||||
.size_full();
|
.size_full()
|
||||||
|
.pt(room)
|
||||||
|
.pb(room);
|
||||||
let (top, focus, input) = (cx.entity(), self.focus.clone(), cx.entity());
|
let (top, focus, input) = (cx.entity(), self.focus.clone(), cx.entity());
|
||||||
let unlock = cx.entity();
|
let unlock = cx.entity();
|
||||||
let banner = self.options.read_only.then(|| {
|
let banner = self.options.read_only.then(|| {
|
||||||
@@ -303,12 +313,22 @@ impl Render for CodeEditor {
|
|||||||
.relative()
|
.relative()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.h_full()
|
.h_full()
|
||||||
|
.min_h_0()
|
||||||
.child(list)
|
.child(list)
|
||||||
.children(sticky)
|
.children(sticky)
|
||||||
.child(
|
.child(
|
||||||
canvas(
|
canvas(
|
||||||
move |bounds, _, cx| {
|
move |bounds, window, cx| {
|
||||||
top.update(cx, |editor, _| editor.metrics.top = bounds.origin.y);
|
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();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|_, _, _, _| {},
|
|_, _, _, _| {},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user