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()
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -33,6 +33,7 @@ mod tasks;
|
||||
mod tests;
|
||||
mod view;
|
||||
mod welcome;
|
||||
mod writing;
|
||||
|
||||
pub use buffer::Buffer;
|
||||
pub use chat::InlineChat;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user