documents and editor: a writing view - measure, font, scale, focus fading, typewriter centering
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e89fb32f43
commit
508fee9d15
13 files changed
+299
-12
No files matched your search
@@ -97,7 +97,10 @@ impl MarkdownDocument {
|
||||
InputEvent::Blur => {
|
||||
document.commit(cx);
|
||||
}
|
||||
InputEvent::Changed => cx.emit(DocumentEvent::Changed),
|
||||
InputEvent::Changed => {
|
||||
document.typed += 1;
|
||||
cx.emit(DocumentEvent::Changed)
|
||||
}
|
||||
_ => {}
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! A writing view: how text shows, not what it says.
|
||||
|
||||
use gpui::{Context, Rems, SharedString};
|
||||
|
||||
use super::MarkdownDocument;
|
||||
use crate::documents::MarkdownMode;
|
||||
|
||||
/// How the document shows its text (MARK-09).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DocumentLook {
|
||||
/// All but the current block or line fade.
|
||||
pub focus: bool,
|
||||
/// The current block or line stays mid-view.
|
||||
pub typewriter: bool,
|
||||
/// The widest the text column grows.
|
||||
pub measure: Option<Rems>,
|
||||
/// The prose font; none keeps the theme's.
|
||||
pub font: Option<SharedString>,
|
||||
/// Text size against the theme's.
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Default for DocumentLook {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
focus: false,
|
||||
typewriter: false,
|
||||
measure: None,
|
||||
font: None,
|
||||
scale: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
pub fn look(&self) -> &DocumentLook {
|
||||
&self.look
|
||||
}
|
||||
|
||||
pub fn set_look(&mut self, look: DocumentLook, cx: &mut Context<Self>) {
|
||||
log::info!("markdown document: look {look:?}");
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.set_scale(look.scale, cx);
|
||||
editor.set_focus_line(look.focus, cx);
|
||||
editor.set_typewriter(look.typewriter, cx);
|
||||
});
|
||||
self.look = look;
|
||||
self.centered = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The reader's block: the open one, or the caret's.
|
||||
pub(super) fn current_block(&self, cx: &gpui::App) -> usize {
|
||||
match &self.open {
|
||||
Some(open) if open.append => self.parsed.blocks.len(),
|
||||
Some(open) => self.block_at(open.range.start),
|
||||
None => self.block_at(self.editor.read(cx).primary().head),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod attach;
|
||||
mod edit;
|
||||
mod finding;
|
||||
mod lists;
|
||||
mod look;
|
||||
mod parse;
|
||||
mod place;
|
||||
mod tables;
|
||||
@@ -19,6 +20,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
pub use attach::Attachment;
|
||||
pub use look::DocumentLook;
|
||||
pub use parse::DocumentHeading;
|
||||
use parse::{Parsed, alike, parsed};
|
||||
pub use tables::{TableEdit, TableRefusal};
|
||||
@@ -83,6 +85,11 @@ pub struct MarkdownDocument {
|
||||
writes: usize,
|
||||
/// A match asked before its text was read; its version.
|
||||
showing: Option<(Range<usize>, u64)>,
|
||||
look: DocumentLook,
|
||||
/// Typing in the open block, counted.
|
||||
typed: u64,
|
||||
/// The block, text, typing and height last centered.
|
||||
centered: Option<(usize, u64, u64, u64)>,
|
||||
focus: FocusHandle,
|
||||
on_link: Option<OnLink>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
@@ -136,6 +143,9 @@ impl MarkdownDocument {
|
||||
asks: 0,
|
||||
writes: 0,
|
||||
showing: None,
|
||||
look: DocumentLook::default(),
|
||||
typed: 0,
|
||||
centered: None,
|
||||
focus,
|
||||
on_link: None,
|
||||
_subscriptions: subscriptions,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use gpui::{TestAppContext, px, rems};
|
||||
|
||||
use super::{MarkdownMode, frames, open};
|
||||
use crate::documents::DocumentLook;
|
||||
|
||||
/// Sixty short paragraphs, each its own block.
|
||||
fn long() -> String {
|
||||
(0..60).map(|n| format!("Paragraph {n}.\n\n")).collect()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_measure_narrows_the_column(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx);
|
||||
let look = DocumentLook {
|
||||
measure: Some(rems(20.)),
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update(cx, |document, cx| document.set_look(look, cx));
|
||||
frames(cx);
|
||||
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
assert!(block.size.width <= px(320.0), "{:?}", block.size);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typewriter_keeps_the_open_block_mid_view(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||
let look = DocumentLook {
|
||||
typewriter: true,
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_look(look, cx);
|
||||
document.open(Some(40), false, window, cx);
|
||||
document.list.scroll_to_reveal_item(40);
|
||||
});
|
||||
frames(cx);
|
||||
let (block, view) = document.read_with(cx, |document, _| {
|
||||
(
|
||||
document.list.bounds_for_item(40),
|
||||
document.list.viewport_bounds(),
|
||||
)
|
||||
});
|
||||
let block = block.expect("block 40 is laid out");
|
||||
let off = (block.center().y - view.center().y).abs();
|
||||
assert!(off < px(4.0), "{off:?} from the middle");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typewriter_keeps_the_source_caret_mid_view(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Source, cx);
|
||||
let look = DocumentLook {
|
||||
typewriter: true,
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update(cx, |document, cx| {
|
||||
document.set_look(look, cx);
|
||||
document.set_caret(90, 0, cx);
|
||||
});
|
||||
frames(cx);
|
||||
let before = document.read_with(cx, |document, cx| document.top_line(cx));
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, gpui::Focusable::focus_handle);
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.simulate_input(
|
||||
"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
",
|
||||
);
|
||||
frames(cx);
|
||||
let after = document.read_with(cx, |document, cx| document.top_line(cx));
|
||||
assert_eq!(after, before + 10, "the view follows the caret");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_scale_sizes_source_lines(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("a\n", MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
frames(cx);
|
||||
let plain = editor.read_with(cx, |editor, _| editor.metrics.line);
|
||||
let look = DocumentLook {
|
||||
scale: 2.0,
|
||||
..DocumentLook::default()
|
||||
};
|
||||
document.update(cx, |document, cx| document.set_look(look, cx));
|
||||
frames(cx);
|
||||
let large = editor.read_with(cx, |editor, _| editor.metrics.line);
|
||||
assert!(large > plain * 1.8, "{plain:?} to {large:?}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.open(Some(3), false, window, cx)
|
||||
});
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.current_block(cx)),
|
||||
3
|
||||
);
|
||||
}
|
||||
@@ -368,6 +368,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
|
||||
mod attach;
|
||||
mod history;
|
||||
mod links;
|
||||
mod look;
|
||||
mod place;
|
||||
mod tools;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement,
|
||||
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
|
||||
StatefulInteractiveElement, Styled, Window, actions, div, list,
|
||||
StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _,
|
||||
};
|
||||
|
||||
use super::MarkdownDocument;
|
||||
@@ -16,6 +16,14 @@ actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]);
|
||||
|
||||
const CONTEXT: &str = "MarkdownDocument";
|
||||
|
||||
/// How far a faded block recedes.
|
||||
const FADED: f32 = 0.35;
|
||||
|
||||
/// Modes with a reader's block to keep.
|
||||
fn editing_mode(mode: MarkdownMode) -> bool {
|
||||
mode == MarkdownMode::Visual
|
||||
}
|
||||
|
||||
/// Undo and redo; Tab shifts an open block's lines.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
let context = Some(CONTEXT);
|
||||
@@ -40,9 +48,11 @@ impl MarkdownDocument {
|
||||
let colors = theme.colors.clone();
|
||||
let (radius, body, mono) = (
|
||||
theme.radius(Radius::Md),
|
||||
theme.text_size(TextSize::Base),
|
||||
theme.text_size(TextSize::Base) * self.look.scale,
|
||||
theme.mono_family.clone(),
|
||||
);
|
||||
// Focus fades all but the reader's block.
|
||||
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
|
||||
let editing = self.mode == MarkdownMode::Visual;
|
||||
let open = self.open.as_ref();
|
||||
let field = |open: &super::Open| {
|
||||
@@ -79,9 +89,11 @@ impl MarkdownDocument {
|
||||
nodes,
|
||||
&self.base,
|
||||
&self.on_link,
|
||||
self.look.scale,
|
||||
cx,
|
||||
)),
|
||||
None => div()
|
||||
.text_size(body)
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.parsed.text[block.range.clone()].to_string()),
|
||||
@@ -92,6 +104,7 @@ impl MarkdownDocument {
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded(radius)
|
||||
.when(faded, |block| block.opacity(FADED))
|
||||
.child(shown);
|
||||
match editing {
|
||||
true => self.pressable(block, Some(ix), cx).into_any_element(),
|
||||
@@ -150,7 +163,21 @@ impl Render for MarkdownDocument {
|
||||
log::info!("markdown document: {} files dropped", dropped.paths().len());
|
||||
cx.emit(super::Attachment::Files(dropped.paths().to_vec()));
|
||||
}));
|
||||
self.center_current(cx);
|
||||
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| {
|
||||
div().size_full().flex().justify_center().child(
|
||||
div()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.when_some(look.measure, |column, measure| column.max_w(measure))
|
||||
.when_some(look.font.clone(), |column, font| column.font_family(font))
|
||||
.child(content),
|
||||
)
|
||||
};
|
||||
let blocks = column(blocks.into_any_element(), &self.look);
|
||||
let source = column(self.editor.clone().into_any_element(), &self.look);
|
||||
match self.mode {
|
||||
MarkdownMode::Source => root
|
||||
.capture_action(cx.listener(|document, _: &crate::editor::Paste, _, cx| {
|
||||
@@ -163,7 +190,7 @@ impl Render for MarkdownDocument {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.child(self.editor.clone())
|
||||
.child(source)
|
||||
.into_any_element(),
|
||||
MarkdownMode::Split => root
|
||||
.flex()
|
||||
|
||||
Reference in new issue
Block a user