documents and editor: a writing view - measure, font, scale, focus fading, typewriter centering
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 12m26s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 08:25:45 -04:00
1 parent e89fb32f43
commit 508fee9d15
13 files changed
+299 -12

No files matched your search

+4 -1
View File
@@ -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)
}
_ => {}
}),
];
+80
View File
@@ -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);
}
}
+10
View File
@@ -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,
+108
View File
@@ -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
);
}
+1
View File
@@ -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;
+30 -3
View File
@@ -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()
+2 -2
View File
@@ -35,8 +35,8 @@ pub use contents::outline;
pub use counts::{ReadingTime, WordCount};
pub use diagram::Diagram;
pub use document::{
Attachment, DocumentEvent, DocumentHeading, LARGEST_READ, MarkdownDocument, TableEdit,
TableRefusal,
Attachment, DocumentEvent, DocumentHeading, DocumentLook, LARGEST_READ, MarkdownDocument,
TableEdit, TableRefusal,
};
pub use epub::{Chapter, EpubReader};
pub use format::{Format, format};
+8 -3
View File
@@ -92,6 +92,8 @@ struct Draw<'a> {
headings: std::cell::Cell<usize>,
code: &'a Option<DrawCode>,
base: &'a Option<Arc<Path>>,
/// Text size against the theme's.
scale: f32,
}
impl Draw<'_> {
@@ -186,7 +188,7 @@ impl Draw<'_> {
};
let heading = div()
.pt_2()
.text_size(theme.text_size(size))
.text_size(theme.text_size(size) * self.scale)
.font_weight(if *level <= 2 {
FontWeight::SEMIBOLD
} else {
@@ -238,11 +240,11 @@ impl Draw<'_> {
.rounded(theme.radius(Radius::Md))
.bg(colors.sunken)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.text_size(theme.text_size(TextSize::Sm) * self.scale)
.text_color(colors.syntax.variable)
.children(language.clone().map(|name| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_size(theme.text_size(TextSize::Xs) * self.scale)
.text_color(colors.fg_subtle)
.child(name)
}))
@@ -422,6 +424,7 @@ pub(crate) fn draw_nodes(
nodes: &[Node],
base: &Option<Arc<Path>>,
on_link: &Option<OnLink>,
scale: f32,
cx: &App,
) -> Vec<AnyElement> {
let draw = Draw {
@@ -432,6 +435,7 @@ pub(crate) fn draw_nodes(
headings: std::cell::Cell::new(0),
code: &None,
base,
scale,
};
draw.nodes(nodes, cx)
}
@@ -448,6 +452,7 @@ impl RenderOnce for MarkdownRenderer {
headings: std::cell::Cell::new(0),
code: &self.code,
base: &self.base,
scale: 1.0,
};
div()
.flex()
+1
View File
@@ -33,6 +33,7 @@ mod tasks;
mod tests;
mod view;
mod welcome;
mod writing;
pub use buffer::Buffer;
pub use chat::InlineChat;
+7
View File
@@ -11,6 +11,9 @@ use super::{
};
use crate::theme::{ActiveTheme, TextSize};
/// How far a faded line recedes.
const FADED: f32 = 0.35;
impl CodeEditor {
/// Row `ix` as the list draws it.
pub(crate) fn row(&self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
@@ -19,7 +22,11 @@ impl CodeEditor {
.w_full()
.h(self.frame.line)
.flex();
let caret = self.buffer.line_of(self.primary().head);
match self.frame.rows[ix] {
Row::Line(line) if self.options.focus_line && line != caret => {
self.line_row(base.opacity(FADED), ix, line, window, cx)
}
Row::Line(line) => self.line_row(base, ix, line, window, cx),
Row::Lens(at) => self.lens_row(base, ix, at, cx),
Row::Removed(hunk, at) => self.removed_row(base, ix, hunk, at, cx),
+12
View File
@@ -64,6 +64,12 @@ pub(crate) struct Options {
pub read_only: bool,
pub tab: usize,
pub highlighter: Option<super::prose::LineHighlighter>,
/// Text size against the theme's.
pub scale: f32,
/// Lines but the caret's fade.
pub focus_line: bool,
/// The caret's line stays mid-view.
pub typewriter: bool,
}
/// What the owner lays over the code.
@@ -114,6 +120,8 @@ pub struct CodeEditor {
/// The text before an input method's composition began, while it runs.
pub(crate) composing: Option<Snapshot>,
pub(crate) metrics: Metrics,
/// The caret row typewriter scrolling last centered.
pub(crate) centered: Option<usize>,
pub(crate) dragging: bool,
pub(crate) caret_on: bool,
/// Blink runs only while focused, or unseen cursors redraw forever.
@@ -191,11 +199,15 @@ impl CodeEditor {
read_only: false,
tab: 4,
highlighter: None,
scale: 1.0,
focus_line: false,
typewriter: false,
},
marks: Marks::default(),
marked: None,
composing: None,
metrics: Metrics::default(),
centered: None,
dragging: false,
caret_on: true,
focused: false,
+10 -3
View File
@@ -2,8 +2,8 @@ use std::ops::Range;
use gpui::{
AnyElement, Bounds, Context, ElementInputHandler, InteractiveElement, IntoElement, MouseButton,
MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Render, Styled, StyledText, Window,
canvas, div, fill, font, point, size, uniform_list,
MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Render, ScrollStrategy, Styled,
StyledText, Window, canvas, div, fill, font, point, size, uniform_list,
};
use super::{
@@ -225,7 +225,7 @@ impl Render for CodeEditor {
let theme = cx.theme();
let colors = theme.colors.clone();
let mono_family = theme.mono_family.clone();
let size = theme.text_size(TextSize::Sm);
let size = theme.text_size(TextSize::Sm) * self.options.scale;
let pixels = size.to_pixels(window.rem_size());
let mono = window
.text_system()
@@ -263,6 +263,13 @@ impl Render for CodeEditor {
};
self.metrics.advance = advance;
self.metrics.line = line;
// Typewriter: the caret's row centers as it moves.
let row = self.primary_row();
if self.options.typewriter && self.centered != Some(row) {
self.scroll
.scroll_to_item_strict(row, ScrollStrategy::Center);
self.centered = Some(row);
}
let sticky = if self.options.sticky {
self.sticky(cx)
} else {
+26
View File
@@ -0,0 +1,26 @@
//! Settings for a writing view over the code.
use gpui::Context;
use super::CodeEditor;
impl CodeEditor {
/// Text size against the theme's, for a writing view.
pub fn set_scale(&mut self, scale: f32, cx: &mut Context<Self>) {
self.options.scale = scale;
cx.notify();
}
/// Fades every line but the caret's.
pub fn set_focus_line(&mut self, on: bool, cx: &mut Context<Self>) {
self.options.focus_line = on;
cx.notify();
}
/// Keeps the caret's line mid-view.
pub fn set_typewriter(&mut self, on: bool, cx: &mut Context<Self>) {
self.options.typewriter = on;
self.centered = None;
cx.notify();
}
}