diff --git a/src/documents/document/edit.rs b/src/documents/document/edit.rs index fc66e2f..e5ebab9 100644 --- a/src/documents/document/edit.rs +++ b/src/documents/document/edit.rs @@ -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) + } _ => {} }), ]; diff --git a/src/documents/document/look.rs b/src/documents/document/look.rs new file mode 100644 index 0000000..0c79cbf --- /dev/null +++ b/src/documents/document/look.rs @@ -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, + /// The prose font; none keeps the theme's. + pub font: Option, + /// 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) { + 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); + } +} diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index d5cb424..5265670 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -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, 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, _subscriptions: Vec, @@ -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, diff --git a/src/documents/document/tests/look.rs b/src/documents/document/tests/look.rs new file mode 100644 index 0000000..8e01a70 --- /dev/null +++ b/src/documents/document/tests/look.rs @@ -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 + ); +} diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index 7ca716c..159bb2f 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -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; diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 5e826ca..328e922 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -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() diff --git a/src/documents/mod.rs b/src/documents/mod.rs index 4a18ba3..d2cd7ae 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -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}; diff --git a/src/documents/render.rs b/src/documents/render.rs index 57f84d5..1968886 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -92,6 +92,8 @@ struct Draw<'a> { headings: std::cell::Cell, code: &'a Option, base: &'a Option>, + /// 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>, on_link: &Option, + scale: f32, cx: &App, ) -> Vec { 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() diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 85bd6ad..642964b 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -33,6 +33,7 @@ mod tasks; mod tests; mod view; mod welcome; +mod writing; pub use buffer::Buffer; pub use chat::InlineChat; diff --git a/src/editor/row.rs b/src/editor/row.rs index 9ee0f37..6ab541d 100644 --- a/src/editor/row.rs +++ b/src/editor/row.rs @@ -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) -> 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), diff --git a/src/editor/state.rs b/src/editor/state.rs index 1265c81..9980e3c 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -64,6 +64,12 @@ pub(crate) struct Options { pub read_only: bool, pub tab: usize, pub highlighter: Option, + /// 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, pub(crate) metrics: Metrics, + /// The caret row typewriter scrolling last centered. + pub(crate) centered: Option, 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, diff --git a/src/editor/view.rs b/src/editor/view.rs index bf4ac28..944056d 100644 --- a/src/editor/view.rs +++ b/src/editor/view.rs @@ -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 { diff --git a/src/editor/writing.rs b/src/editor/writing.rs new file mode 100644 index 0000000..9ce7b8e --- /dev/null +++ b/src/editor/writing.rs @@ -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.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.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.options.typewriter = on; + self.centered = None; + cx.notify(); + } +}