Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
236 lines
8.4 KiB
Rust
236 lines
8.4 KiB
Rust
use std::sync::Arc;
|
|
|
|
use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px};
|
|
|
|
use super::{
|
|
MarkdownDocument, Placement,
|
|
edit::{between, moved},
|
|
};
|
|
use crate::documents::{MarkdownMode, modes::joint};
|
|
|
|
/// Line and column, from zero, of byte `at` in `text`.
|
|
fn point(text: &str, at: usize) -> (usize, usize) {
|
|
let before = &text[..at];
|
|
let line_start = before.rfind('\n').map_or(0, |newline| newline + 1);
|
|
(
|
|
before.matches('\n').count(),
|
|
before[line_start..].chars().count(),
|
|
)
|
|
}
|
|
|
|
impl MarkdownDocument {
|
|
/// The caret's line and column from zero, every mode.
|
|
pub fn caret(&self, cx: &App) -> (usize, usize) {
|
|
let editor = self.editor.read(cx);
|
|
let Some(open) = &self.open else {
|
|
return point(editor.text(), editor.primary().head);
|
|
};
|
|
let input = open.input.read(cx);
|
|
let joined = match open.append && !input.text().is_empty() {
|
|
true => joint(editor.text()).len(),
|
|
false => 0,
|
|
};
|
|
point(&self.text(cx), open.range.start + joined + input.cursor())
|
|
}
|
|
|
|
/// The selected Markdown: the source's, or the open block's.
|
|
pub fn selected(&self, cx: &App) -> String {
|
|
let Some(open) = &self.open else {
|
|
// Rendered modes show no source selection.
|
|
if !matches!(self.mode, MarkdownMode::Source | MarkdownMode::Split) {
|
|
return String::new();
|
|
}
|
|
let editor = self.editor.read(cx);
|
|
return editor.text()[editor.primary().range()].to_string();
|
|
};
|
|
let input = open.input.read(cx);
|
|
input.text()[input.selection()].to_string()
|
|
}
|
|
|
|
/// The caret to `line` and `column`, clamped to text.
|
|
pub fn set_caret(&mut self, line: usize, column: usize, cx: &mut Context<Self>) {
|
|
self.commit(cx);
|
|
let editor = self.editor.read(cx);
|
|
let range = editor
|
|
.buffer
|
|
.line_range(line.min(editor.buffer.lines() - 1));
|
|
let text = &editor.text()[range.clone()];
|
|
let within = text
|
|
.char_indices()
|
|
.nth(column)
|
|
.map_or(text.len(), |(at, _)| at);
|
|
let at = range.start + within;
|
|
self.editor
|
|
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
|
cx.notify();
|
|
}
|
|
|
|
/// The first line in view; block lines share its height.
|
|
pub fn top_line(&self, cx: &App) -> usize {
|
|
let editor = self.editor.read(cx);
|
|
if let Some(top) = self.top {
|
|
return top.line.min(editor.buffer.lines() - 1);
|
|
}
|
|
match self.mode {
|
|
MarkdownMode::Source | MarkdownMode::Split => editor.top_line().unwrap_or(0),
|
|
MarkdownMode::Visual | MarkdownMode::Preview => {
|
|
let ListOffset {
|
|
item_ix,
|
|
offset_in_item,
|
|
} = self.list.logical_scroll_top();
|
|
let Some(block) = self.parsed.blocks.get(item_ix) else {
|
|
return editor.buffer.lines() - 1;
|
|
};
|
|
let first = editor.buffer.line_of(block.range.start);
|
|
let lines = editor.buffer.line_of(block.range.end) + 1 - first;
|
|
let into = self.list.bounds_for_item(item_ix).map_or(0.0, |bounds| {
|
|
offset_in_item.as_f32() / bounds.size.height.as_f32().max(1.0)
|
|
});
|
|
first + ((into * lines as f32).round() as usize).min(lines - 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scrolls `line` to the top; the caret stays.
|
|
pub fn set_top_line(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.asks += 1;
|
|
self.top = Some(Placement {
|
|
line,
|
|
ask: self.asks,
|
|
placing: false,
|
|
});
|
|
self.place_top(window, cx);
|
|
}
|
|
|
|
/// A placement in flight goes stale; it is placed again.
|
|
pub(super) fn replace_top(&mut self) {
|
|
if let Some(top) = &mut self.top {
|
|
self.asks += 1;
|
|
(top.ask, top.placing) = (self.asks, false);
|
|
}
|
|
}
|
|
|
|
/// Moves the view to the asked top once read.
|
|
pub(super) fn place_top(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(Placement {
|
|
line,
|
|
ask,
|
|
placing: false,
|
|
}) = self.top
|
|
else {
|
|
return;
|
|
};
|
|
let editor = self.editor.read(cx);
|
|
let line = line.min(editor.buffer.lines() - 1);
|
|
if matches!(self.mode, MarkdownMode::Source | MarkdownMode::Split) {
|
|
editor
|
|
.scroll
|
|
.scroll_to_item_strict(line + editor.pad, ScrollStrategy::Top);
|
|
self.placing(ask);
|
|
// The editor's offset holds it from the next frame.
|
|
return cx.on_next_frame(window, move |document, _, cx| {
|
|
document.placed(ask, None, cx)
|
|
});
|
|
}
|
|
if !self.read_now(cx) {
|
|
return;
|
|
}
|
|
let editor = self.editor.read(cx);
|
|
let at = editor.buffer.line_range(line).start;
|
|
let item_ix = self.block_at(at);
|
|
let Some(block) = self.parsed.blocks.get(item_ix) else {
|
|
self.top = None;
|
|
return;
|
|
};
|
|
let first = editor.buffer.line_of(block.range.start);
|
|
let lines = editor.buffer.line_of(block.range.end) + 1 - first;
|
|
let into = line.saturating_sub(first) as f32 / lines as f32;
|
|
self.list.scroll_to(ListOffset {
|
|
item_ix,
|
|
offset_in_item: px(0.),
|
|
});
|
|
self.placing(ask);
|
|
cx.notify();
|
|
// A block's height is known once drawn.
|
|
cx.on_next_frame(window, move |document, _, cx| {
|
|
document.placed(ask, Some((item_ix, into)), cx)
|
|
});
|
|
}
|
|
|
|
fn placing(&mut self, ask: u64) {
|
|
if let Some(top) = self.top.as_mut().filter(|top| top.ask == ask) {
|
|
top.placing = true;
|
|
}
|
|
}
|
|
|
|
/// A frame after placing ask `ask`; stale asks do nothing.
|
|
fn placed(&mut self, ask: u64, within: Option<(usize, f32)>, cx: &mut Context<Self>) {
|
|
if self.top.is_none_or(|top| top.ask != ask) {
|
|
return;
|
|
}
|
|
if let Some((item_ix, into)) = within
|
|
&& let Some(bounds) = self.list.bounds_for_item(item_ix)
|
|
{
|
|
self.list.scroll_to(ListOffset {
|
|
item_ix,
|
|
offset_in_item: bounds.size.height * into,
|
|
});
|
|
}
|
|
self.top = None;
|
|
// The view moved: center again.
|
|
self.centered = None;
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.centered = None;
|
|
cx.notify();
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
/// The block holding byte `at`, or the one before it.
|
|
pub(super) fn block_at(&self, at: usize) -> usize {
|
|
self.parsed
|
|
.blocks
|
|
.partition_point(|block| block.range.start <= at)
|
|
.saturating_sub(1)
|
|
}
|
|
|
|
/// Reveals the pressed heading, moved by the edit since.
|
|
pub(super) fn reveal_at(&mut self, at: usize, window: &mut Window, cx: &mut Context<Self>) {
|
|
if !self.parsed.headings.iter().any(|heading| heading.at == at) {
|
|
return log::warn!("markdown document: a heading read away");
|
|
}
|
|
// The heading list drew from this read.
|
|
let drawn = Arc::clone(&self.parsed);
|
|
if self.commit(cx).is_some() || !self.read_now(cx) {
|
|
self.reparse(true, cx);
|
|
}
|
|
if !self.read_now(cx) {
|
|
return log::warn!("markdown document: a heading pressed while the text is read again");
|
|
}
|
|
let edit = between(&drawn.text, self.editor.read(cx).text());
|
|
let headings = &self.parsed.headings;
|
|
let Some(found) = moved(at, &edit).filter(|at| headings.iter().any(|h| h.at == *at)) else {
|
|
return log::warn!("markdown document: the pressed heading changed as one wrote");
|
|
};
|
|
let line = self.editor.read(cx).buffer.line_of(found);
|
|
self.reveal(line, window, cx);
|
|
}
|
|
|
|
/// Line `line` at top and focused, caret at start.
|
|
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.set_caret(line, 0, cx);
|
|
self.set_top_line(line, window, cx);
|
|
let focus = self.focus_handle(cx);
|
|
window.focus(&focus, cx);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn points_count_lines_and_characters() {
|
|
assert_eq!(super::point("ab\nçd\n", 6), (1, 2));
|
|
assert_eq!(super::point("", 0), (0, 0));
|
|
}
|
|
}
|