GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/doc_editor/mod.rs (54.9K)
1 //! `DocEditor`: a document editor with Markdown live preview — cce-notes'
2 //! editing mode (Obsidian-on-cce milestone 6), and a fast plain editor with
3 //! preview off.
4 //!
5 //! - [`buffer`]: the text as lines, caret and selection, undoable edits.
6 //! - [`preview`]: how a line shows — markup hidden except on the caret's
7 //! line, where it shows dimmed (Obsidian's live preview).
8 //! - [`layout`]: one styled line wrapped into runs, with the x of every
9 //! byte, so drawing, caret and clicks agree.
10 //!
11 //! **Incremental.** A line is laid out (shaped) only when it is drawn and
12 //! has changed: an edit invalidates the lines it replaced
13 //! ([`buffer::Change`]), a moved caret the lines it left and entered (their
14 //! markers show or hide), a fence the block contexts after it. Lines never
15 //! drawn keep an estimated height, so opening a long file shapes one
16 //! screen of it.
17 //!
18 //! **Driven by its host, not a registered widget.** The app forwards keys,
19 //! pointer events and the wheel, and paints it into its display list
20 //! ([`DocEditor::paint`]); answers come back as [`Response`] — a link to
21 //! follow is the host's to resolve. The caret does not blink: a blinking
22 //! caret is a frame every half second for as long as the window is open.
23
24 pub mod buffer;
25 pub mod layout;
26 pub mod preview;
27
28 use std::time::Duration;
29 use web_time::Instant;
30
31 pub use buffer::{Buffer, EditKind, Pos};
32 pub use layout::EditorTheme;
33 pub use preview::Target;
34
35 use crate::scene::layout::Rect;
36 use crate::scene::paint::{Cap, PaintCtx};
37 use crate::widget::shaping::ShapingMeasure;
38 use crate::widget::{Bounds, EmbedImage, Key, KeyEvent, MouseScrollDelta, NamedKey, ScrollMotion};
39 use layout::{Deco, LineLayout};
40 use preview::{Context, Kind, Marker, Prop};
41
42 /// What a key or click did, for the host.
43 #[derive(Clone, Debug, PartialEq)]
44 pub enum Response {
45 None,
46 /// The caret or selection moved; the text did not change.
47 Moved,
48 /// The text changed.
49 Changed,
50 /// A link was clicked (rendered) or Ctrl+clicked (raw).
51 Follow(Target),
52 }
53
54 const DOUBLE_CLICK: Duration = Duration::from_millis(400);
55
56 pub struct DocEditor {
57 pub buf: Buffer,
58 /// Live preview: markup hidden except on the caret's lines. Off: every
59 /// line shows as written (with its syntax still dimmed).
60 pub preview: bool,
61 pub theme: EditorTheme,
62 /// The widest the text column grows (Obsidian's readable line length);
63 /// 0 lets it fill the rect.
64 pub max_width: f32,
65 pub pad: f32,
66 measure: ShapingMeasure,
67 ctx: Vec<Context>,
68 /// The frontmatter lines' Properties roles (`None` elsewhere).
69 props: Vec<Option<Prop>>,
70 layouts: Vec<Option<LineLayout>>,
71 heights: Vec<f32>,
72 tops: Vec<f32>,
73 tops_dirty: bool,
74 /// Lines laid out as active (raw), so a caret move relays out the lines
75 /// it leaves and the ones it enters.
76 shown_active: (usize, usize),
77 width: f32,
78 pub scroll: f32,
79 motion: ScrollMotion,
80 /// The x a vertical move keeps to.
81 want_x: Option<f32>,
82 dragging: bool,
83 clicks: Option<(Instant, Pos, u8)>,
84 /// Where the text column was last painted: its origin and the rect.
85 origin: (f32, f32),
86 viewport: Rect,
87 follow_caret: bool,
88 /// The host's embedded images, by link text (see [`DocEditor::set_images`]).
89 images: Option<Box<dyn Fn(&str) -> Option<EmbedImage>>>,
90 /// An input method's composition (see `crate::ime`), at the source
91 /// position it began, and the `ime::generation` it was taken at. It is
92 /// in the caret line's LAYOUT, never in the buffer — the buffer is what
93 /// the host saves — so every column read off that line's layout is
94 /// mapped across it (`laid_col`, `source_col`).
95 composition: Option<(Pos, crate::ime::Preedit)>,
96 ime_seen: u64,
97 }
98
99 impl DocEditor {
100 /// An editor over `text`. `system_fonts` must match the app's
101 /// `Application::load_system_fonts`, so measured widths are drawn ones.
102 pub fn new(text: &str, theme: EditorTheme, system_fonts: bool) -> DocEditor {
103 let mut e = DocEditor {
104 buf: Buffer::new(text),
105 preview: true,
106 theme,
107 max_width: 0.0,
108 pad: 24.0,
109 measure: ShapingMeasure::new(system_fonts),
110 ctx: Vec::new(),
111 props: Vec::new(),
112 layouts: Vec::new(),
113 heights: Vec::new(),
114 tops: Vec::new(),
115 tops_dirty: true,
116 shown_active: (usize::MAX, usize::MAX),
117 width: 0.0,
118 scroll: 0.0,
119 motion: ScrollMotion::new(),
120 want_x: None,
121 dragging: false,
122 clicks: None,
123 origin: (0.0, 0.0),
124 viewport: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
125 follow_caret: false,
126 images: None,
127 composition: None,
128 ime_seen: 0,
129 };
130 e.sync();
131 e.caret_to_body();
132 e
133 }
134
135 pub fn set_text(&mut self, text: &str) {
136 self.buf.set_text(text);
137 self.scroll = 0.0;
138 self.motion = ScrollMotion::new();
139 self.want_x = None;
140 self.sync();
141 self.caret_to_body();
142 }
143
144 /// Start the caret on the body, past a frontmatter block, so the
145 /// Properties table shows rather than its raw YAML.
146 fn caret_to_body(&mut self) {
147 if let Some(close) = self.props.iter().position(|p| *p == Some(Prop::Close)) {
148 self.buf.caret = Pos::new((close + 1).min(self.buf.line_count() - 1), 0);
149 self.sync();
150 }
151 }
152
153 pub fn text(&self) -> String {
154 self.buf.text()
155 }
156
157 /// Where embedded images come from: `images` answers an embed's link
158 /// text with the host's uploaded image, or `None` (not an image, or
159 /// not loaded yet — the line shows as text). In live preview a line
160 /// that is one embed shows as its image, and with the caret on it as
161 /// the raw text with the image below. Asked at layout for the size and
162 /// again at paint for the id; call [`DocEditor::invalidate`] when an
163 /// image arrives or changes size.
164 pub fn set_images(&mut self, images: Box<dyn Fn(&str) -> Option<EmbedImage>>) {
165 self.images = Some(images);
166 self.invalidate();
167 }
168
169 /// Drop every layout (fonts or the theme changed).
170 pub fn invalidate(&mut self) {
171 self.layouts.fill(None);
172 self.tops_dirty = true;
173 }
174
175 pub fn set_preview(&mut self, on: bool) {
176 if self.preview != on {
177 self.preview = on;
178 self.invalidate();
179 }
180 }
181
182 // ---- incremental bookkeeping ----------------------------------------
183
184 /// The lines shown raw: the caret's (or the selection's), widened to
185 /// a fenced code block or the frontmatter the caret is in, so its
186 /// fences (or its raw YAML) show.
187 fn active_range(&self) -> (usize, usize) {
188 let (mut a, mut b) = match self.buf.selection() {
189 Some((a, b)) => (a.line, b.line),
190 None => (self.buf.caret.line, self.buf.caret.line),
191 };
192 // The frontmatter shows raw while the caret is anywhere in it, as
193 // a fenced block does; elsewhere it is the Properties table.
194 if let Some((x, _)) = preview::frontmatter_block(&self.ctx, a) {
195 a = x;
196 }
197 if let Some((_, y)) = preview::frontmatter_block(&self.ctx, b) {
198 b = y;
199 }
200 if let Some((x, _)) = preview::fenced_block(&self.ctx, a) {
201 a = x;
202 }
203 if let Some((_, y)) = preview::fenced_block(&self.ctx, b) {
204 b = y;
205 }
206 (a, b)
207 }
208
209 fn is_active(&self, i: usize, act: (usize, usize)) -> bool {
210 !self.preview || (i >= act.0 && i <= act.1)
211 }
212
213 fn estimate(&self, i: usize) -> f32 {
214 let row = (self.theme.size * self.theme.spacing).ceil();
215 let chars = self.buf.line(i).chars().count() as f32;
216 let per_row = (self.width / (self.theme.size * 0.5)).max(10.0);
217 row * (chars / per_row).ceil().max(1.0)
218 }
219
220 /// Bring layouts, contexts and heights in line with the buffer.
221 fn sync(&mut self) {
222 for c in self.buf.take_changes() {
223 if c.removed == usize::MAX {
224 self.layouts = vec![None; self.buf.line_count()];
225 self.heights = vec![0.0; self.buf.line_count()];
226 self.ctx.clear();
227 continue;
228 }
229 let end = (c.first + c.removed).min(self.layouts.len());
230 self.layouts.splice(c.first..end, std::iter::repeat_with(|| None).take(c.inserted));
231 self.heights.splice(c.first..end, std::iter::repeat_n(0.0, c.inserted));
232 self.tops_dirty = true;
233 }
234 let n = self.buf.line_count();
235 self.layouts.resize_with(n, || None);
236 self.heights.resize(n, 0.0);
237 let ctx = preview::contexts(self.buf.lines());
238 if ctx.len() == self.ctx.len() {
239 for i in 0..n {
240 if ctx[i] != self.ctx[i] {
241 self.layouts[i] = None;
242 }
243 }
244 } else {
245 self.layouts.fill(None);
246 }
247 self.ctx = ctx;
248 let props = preview::properties(self.buf.lines(), &self.ctx);
249 for (i, p) in props.iter().enumerate() {
250 if self.props.get(i) != Some(p) {
251 self.layouts[i] = None;
252 }
253 }
254 self.props = props;
255 let act = self.active_range();
256 if act != self.shown_active {
257 let (a, b) = self.shown_active;
258 for i in [a, b, act.0, act.1] {
259 if i < n {
260 self.layouts[i] = None;
261 }
262 }
263 // A selection's middle lines too.
264 for i in act.0.min(n)..=act.1.min(n.saturating_sub(1)) {
265 self.layouts[i] = None;
266 }
267 if a != usize::MAX {
268 for i in a.min(n)..=b.min(n.saturating_sub(1)) {
269 self.layouts[i] = None;
270 }
271 }
272 self.shown_active = act;
273 }
274 for i in 0..n {
275 let h = match &self.layouts[i] {
276 Some(l) => l.height,
277 None if self.heights[i] > 0.0 => self.heights[i],
278 None => self.estimate(i),
279 };
280 if (h - self.heights[i]).abs() > 0.01 {
281 self.heights[i] = h;
282 self.tops_dirty = true;
283 }
284 }
285 self.retop();
286 }
287
288 fn retop(&mut self) {
289 if !self.tops_dirty && self.tops.len() == self.heights.len() + 1 {
290 return;
291 }
292 self.tops.clear();
293 self.tops.reserve(self.heights.len() + 1);
294 let mut y = 0.0;
295 for h in &self.heights {
296 self.tops.push(y);
297 y += h;
298 }
299 self.tops.push(y);
300 self.tops_dirty = false;
301 }
302
303 fn ensure(&mut self, i: usize) {
304 if i >= self.layouts.len() || self.layouts[i].is_some() {
305 return;
306 }
307 let act = self.shown_active;
308 let active = self.is_active(i, act);
309 // The caret line with a composition in it, laid out as typed.
310 let composed = self.composition.as_ref().filter(|(at, _)| at.line == i).map(|(at, p)| {
311 let mut t = self.buf.line(i).to_string();
312 t.insert_str(at.col.min(t.len()), &p.text);
313 t
314 });
315 let text = composed.as_deref().unwrap_or(self.buf.line(i));
316 let line = match self.props.get(i) {
317 Some(Some(p)) if !active => {
318 let key = match p {
319 Prop::Item { key_line: Some(k), .. } => preview::prop_key(self.buf.line(*k)),
320 _ => None,
321 };
322 preview::style_property(text, p, key)
323 }
324 _ => preview::style_line(text, self.ctx[i], active),
325 };
326 let mut l = layout::layout_line(text, &line, active, self.width, &self.theme, &mut self.measure);
327 if self.preview && self.ctx[i] == Context::Normal {
328 if let Some((target, want_w, want_h)) = preview::standalone_embed(text) {
329 if let Some(img) = self.images.as_ref().and_then(|f| f(&target)) {
330 let (w, h) = img.fit(want_w, want_h, self.width);
331 l = if active { l.with_image_below(&target, w, h) } else { LineLayout::image(&target, w, h, l.text_size) };
332 }
333 } else if text.contains("![") {
334 // Embeds inside the text: their pictures in a row below it.
335 let width = self.width;
336 let found: Vec<(String, f32, f32)> = preview::inline_embeds(text)
337 .into_iter()
338 .filter_map(|(target, want_w, want_h)| {
339 let img = self.images.as_ref().and_then(|f| f(&target))?;
340 let (w, h) = img.fit(want_w, want_h, width);
341 Some((target, w, h))
342 })
343 .collect();
344 l = l.with_images_below(&found, width);
345 }
346 }
347 if (l.height - self.heights[i]).abs() > 0.01 {
348 self.heights[i] = l.height;
349 self.tops_dirty = true;
350 }
351 self.layouts[i] = Some(l);
352 }
353
354 fn line_at_y(&self, y: f32) -> usize {
355 let n = self.heights.len();
356 match self.tops[..n].binary_search_by(|t| t.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Less)) {
357 Ok(i) => i,
358 Err(0) => 0,
359 Err(i) => (i - 1).min(n - 1),
360 }
361 }
362
363 pub fn content_height(&self) -> f32 {
364 self.tops.last().copied().unwrap_or(0.0) + 2.0 * self.pad
365 }
366
367 fn max_scroll(&self) -> f32 {
368 (self.content_height() - self.viewport.height).max(0.0)
369 }
370
371 // ---- the input method ---------------------------------------------------
372
373 /// Take up the input method's composition, if it has moved since the
374 /// editor last did: the caret line relaid out with it in. A composition
375 /// begun over a selection replaces it, as typing would.
376 fn sync_ime(&mut self) {
377 let generation = crate::ime::generation();
378 if generation == self.ime_seen {
379 return;
380 }
381 self.ime_seen = generation;
382 let next = crate::ime::preedit();
383 if next.is_some() && self.composition.is_none() && self.buf.delete_selection() {
384 self.sync();
385 }
386 if let Some((at, _)) = self.composition.take() {
387 self.relayout(at.line);
388 }
389 if let Some(p) = next {
390 let at = self.buf.caret;
391 self.buf.anchor = None;
392 self.composition = Some((at, p));
393 self.relayout(at.line);
394 self.follow_caret = true;
395 }
396 }
397
398 /// The editor lost the keyboard, or a press moved its caret: a
399 /// composition it was showing is dropped, and the input method asked to
400 /// cancel it.
401 pub fn drop_composition(&mut self) {
402 if let Some((at, _)) = self.composition.take() {
403 self.relayout(at.line);
404 crate::ime::request_reset();
405 }
406 self.ime_seen = crate::ime::generation();
407 }
408
409 /// Whether an input method's composition is showing.
410 pub fn composing(&self) -> bool {
411 self.composition.is_some()
412 }
413
414 fn relayout(&mut self, line: usize) {
415 if let Some(l) = self.layouts.get_mut(line) {
416 *l = None;
417 }
418 self.tops_dirty = true;
419 }
420
421 /// The composition on `line`: its source column, its length in bytes,
422 /// and the bytes into it the input method has its cursor.
423 fn composition_on(&self, line: usize) -> Option<(usize, usize, usize)> {
424 let (at, p) = self.composition.as_ref().filter(|(at, _)| at.line == line)?;
425 let caret = p.text.char_indices().nth(p.caret_chars()).map_or(p.text.len(), |(b, _)| b);
426 Some((at.col, p.text.len(), caret))
427 }
428
429 /// A source column of `line` as a column of its layout: past a
430 /// composition there, moved over it; the caret itself, where the input
431 /// method has its cursor in it.
432 fn laid_col(&self, line: usize, col: usize, is_caret: bool) -> usize {
433 match self.composition_on(line) {
434 Some((c, _, caret)) if is_caret && col == c => c + caret,
435 Some((c, len, _)) if col > c => col + len,
436 _ => col,
437 }
438 }
439
440 /// A column of `line`'s layout as a source column: inside a composition
441 /// is where it began.
442 fn source_col(&self, line: usize, col: usize) -> usize {
443 match self.composition_on(line) {
444 Some((c, len, _)) if col > c && col < c + len => c,
445 Some((c, len, _)) if col >= c + len => col - len,
446 _ => col,
447 }
448 }
449
450 // ---- caret geometry -----------------------------------------------------
451
452 /// The caret's (x, y) in content space and its row height.
453 fn caret_point(&mut self, p: Pos) -> (f32, f32, f32) {
454 self.ensure(p.line);
455 self.retop();
456 let col = self.laid_col(p.line, p.col, p == self.buf.caret);
457 let l = self.layouts[p.line].as_ref().expect("laid out");
458 let (x, row) = l.caret_xy(col);
459 (x, self.tops[p.line] + row as f32 * l.row_h, l.row_h)
460 }
461
462 /// The caret's rect on screen as last painted — for a popup at it.
463 pub fn caret_rect(&mut self) -> Rect {
464 let (x, y, h) = self.caret_point(self.buf.caret);
465 Rect { x: self.origin.0 + x, y: self.origin.1 + y - self.scroll, width: 2.0, height: h }
466 }
467
468 /// The position under a screen point.
469 /// The text position under a window point (as painted last), for a
470 /// host placing something there — a drop.
471 pub fn pos_at(&mut self, sx: f32, sy: f32) -> Pos {
472 let (x, y) = (sx - self.origin.0, sy - self.origin.1 + self.scroll);
473 if y < 0.0 {
474 return Pos::default();
475 }
476 let i = self.line_at_y(y);
477 self.ensure(i);
478 self.retop();
479 let l = self.layouts[i].as_ref().unwrap();
480 let row = (((y - self.tops[i]) / l.row_h).floor().max(0.0) as usize).min(l.rows - 1);
481 let col = l.col_at(x, row);
482 Pos::new(i, self.source_col(i, col))
483 }
484
485 fn scroll_to_caret(&mut self) {
486 let (_, y, h) = self.caret_point(self.buf.caret);
487 let view = self.viewport.height - 2.0 * self.pad;
488 if view <= 0.0 {
489 return;
490 }
491 if y < self.scroll {
492 self.scroll = y;
493 } else if y + h > self.scroll + view {
494 self.scroll = y + h - view;
495 }
496 self.scroll = self.scroll.clamp(0.0, self.max_scroll());
497 self.motion.y.jump_to(self.scroll);
498 }
499
500 /// Move the caret one visual row (or `rows`) up or down, keeping `want_x`.
501 fn vertical(&mut self, rows: i32, select: bool) {
502 let p = self.buf.caret;
503 self.ensure(p.line);
504 let (cx, row) = self.layouts[p.line].as_ref().unwrap().caret_xy(p.col);
505 let x = *self.want_x.get_or_insert(cx);
506 let (mut line, mut row) = (p.line as i64, row as i64);
507 let mut left = rows.unsigned_abs();
508 while left > 0 {
509 row += rows.signum() as i64;
510 let rows_here = self.layouts[line as usize].as_ref().map_or(1, |l| l.rows) as i64;
511 if row < 0 {
512 if line == 0 {
513 row = 0;
514 break;
515 }
516 line -= 1;
517 self.ensure(line as usize);
518 row = self.layouts[line as usize].as_ref().unwrap().rows as i64 - 1;
519 } else if row >= rows_here {
520 if line as usize + 1 >= self.buf.line_count() {
521 row = rows_here - 1;
522 break;
523 }
524 line += 1;
525 self.ensure(line as usize);
526 row = 0;
527 }
528 left -= 1;
529 }
530 let l = self.layouts[line as usize].as_ref().unwrap();
531 let col = l.col_at(x, row as usize);
532 let keep = self.want_x;
533 self.buf.set_caret(Pos::new(line as usize, col), select);
534 self.want_x = keep;
535 }
536
537 // ---- input --------------------------------------------------------------
538
539 /// A key press. Undo/redo chords are the host's to route (the runner
540 /// sends them to `Application::undo`); call [`DocEditor::undo`] there.
541 pub fn key(&mut self, ev: &KeyEvent) -> Response {
542 self.sync();
543 // While an input method composes, its keys are its own: a shell does
544 // not deliver them, and one that does is not editing this text.
545 self.sync_ime();
546 if self.composition.is_some() {
547 return Response::None;
548 }
549 let rev = self.buf.revision;
550 let caret = (self.buf.caret, self.buf.anchor);
551 let (ctrl, shift) = (ev.ctrl, ev.shift);
552 let mut keep_x = false;
553 match &ev.logical_key {
554 Key::Named(NamedKey::ArrowLeft) => {
555 let to = match (self.buf.selection(), shift, ctrl) {
556 (Some((a, _)), false, false) => a,
557 (_, _, true) => self.buf.word_left(self.buf.caret),
558 _ => self.buf.prev(self.buf.caret),
559 };
560 self.buf.set_caret(to, shift);
561 }
562 Key::Named(NamedKey::ArrowRight) => {
563 let to = match (self.buf.selection(), shift, ctrl) {
564 (Some((_, b)), false, false) => b,
565 (_, _, true) => self.buf.word_right(self.buf.caret),
566 _ => self.buf.next(self.buf.caret),
567 };
568 self.buf.set_caret(to, shift);
569 }
570 Key::Named(NamedKey::ArrowUp) => {
571 self.vertical(-1, shift);
572 keep_x = true;
573 }
574 Key::Named(NamedKey::ArrowDown) => {
575 self.vertical(1, shift);
576 keep_x = true;
577 }
578 Key::Named(NamedKey::PageUp) | Key::Named(NamedKey::PageDown) => {
579 let row = (self.theme.size * self.theme.spacing).max(1.0);
580 let rows = ((self.viewport.height - 2.0 * self.pad) / row).floor().max(1.0) as i32;
581 let dir = if matches!(ev.logical_key, Key::Named(NamedKey::PageUp)) { -1 } else { 1 };
582 self.vertical(dir * rows, shift);
583 keep_x = true;
584 }
585 Key::Named(NamedKey::Home) => {
586 let to = if ctrl {
587 Pos::default()
588 } else {
589 // Smart home: the content start first, then column 0.
590 let p = self.buf.caret;
591 self.ensure(p.line);
592 let start = self.content_start(p.line);
593 Pos::new(p.line, if p.col > start { start } else { 0 })
594 };
595 self.buf.set_caret(to, shift);
596 }
597 Key::Named(NamedKey::End) => {
598 let to = if ctrl { self.buf.end() } else { Pos::new(self.buf.caret.line, self.buf.line(self.buf.caret.line).len()) };
599 self.buf.set_caret(to, shift);
600 }
601 Key::Named(NamedKey::Backspace) => self.buf.backspace(ctrl),
602 Key::Named(NamedKey::Delete) => self.buf.delete_forward(ctrl),
603 Key::Named(NamedKey::Enter) => self.enter(),
604 Key::Named(NamedKey::Tab) => self.tab(shift),
605 Key::Named(NamedKey::Escape) => {
606 if self.buf.anchor.is_some() {
607 self.buf.anchor = None;
608 } else {
609 return Response::None;
610 }
611 }
612 Key::Character(c) if ctrl => match c.to_lowercase().as_str() {
613 "a" => self.buf.select_all(),
614 "c" => {
615 if let Some(t) = self.buf.selected_text() {
616 crate::widget::clipboard::copy_to_clipboard(&t);
617 }
618 }
619 "x" => {
620 if let Some(t) = self.buf.selected_text() {
621 crate::widget::clipboard::copy_to_clipboard(&t);
622 self.buf.delete_selection();
623 }
624 }
625 "v" => {
626 if let Some(t) = crate::widget::clipboard::read_from_clipboard() {
627 self.buf.insert(&t.replace("\r\n", "\n"), EditKind::Other);
628 }
629 }
630 "b" => self.wrap("**"),
631 "i" => self.wrap("*"),
632 _ => return Response::None,
633 },
634 _ => {
635 let Some(text) = ev.text.as_deref() else { return Response::None };
636 if ctrl || ev.alt || text.is_empty() || text.chars().any(|c| c.is_control()) {
637 return Response::None;
638 }
639 let kind = if text.chars().all(char::is_whitespace) { EditKind::Other } else { EditKind::Typing };
640 self.buf.insert(text, kind);
641 }
642 }
643 if !keep_x {
644 self.want_x = None;
645 }
646 self.after_edit(rev, caret)
647 }
648
649 fn after_edit(&mut self, rev: u64, caret: (Pos, Option<Pos>)) -> Response {
650 if self.buf.revision != rev {
651 self.sync();
652 self.follow_caret = true;
653 Response::Changed
654 } else if (self.buf.caret, self.buf.anchor) != caret {
655 self.sync();
656 self.follow_caret = true;
657 Response::Moved
658 } else {
659 Response::None
660 }
661 }
662
663 fn content_start(&self, line: usize) -> usize {
664 preview::style_line(self.buf.line(line), self.ctx.get(line).copied().unwrap_or(Context::Normal), true).content_start
665 }
666
667 /// Enter continues a list item (`- `, `1. ` counting on, `- [ ] `),
668 /// and on an empty item ends the list instead, as Obsidian does.
669 fn enter(&mut self) {
670 let p = self.buf.caret;
671 let text = self.buf.line(p.line).to_string();
672 let ctx = self.ctx.get(p.line).copied().unwrap_or(Context::Normal);
673 let line = preview::style_line(&text, ctx, true);
674 if self.buf.selection().is_none() {
675 if let Kind::List { marker, .. } = &line.kind {
676 let body = &text[line.content_start..];
677 if body.trim().is_empty() && p.col >= line.content_start {
678 // An empty item: drop its marker, leave the list.
679 self.buf.replace(Pos::new(p.line, 0), Pos::new(p.line, text.len()), "", EditKind::Other);
680 return;
681 }
682 let indent: String = text.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
683 let next = match marker {
684 Marker::Bullet => format!("{indent}{} ", text.trim_start().chars().next().unwrap_or('-')),
685 Marker::Number(n) => {
686 let digits: String = n.chars().filter(char::is_ascii_digit).collect();
687 let delim = n.chars().last().unwrap_or('.');
688 format!("{indent}{}{delim} ", digits.parse::<u64>().unwrap_or(0) + 1)
689 }
690 Marker::Task(..) => {
691 let bullet = text.trim_start().chars().next().unwrap_or('-');
692 format!("{indent}{bullet} [ ] ")
693 }
694 };
695 if p.col >= line.content_start {
696 self.buf.insert(&format!("\n{next}"), EditKind::Other);
697 return;
698 }
699 }
700 }
701 self.buf.insert("\n", EditKind::Other);
702 }
703
704 /// Tab indents a list item (or the selected lines) by a tab; Shift+Tab
705 /// takes one level off. Anywhere else Tab inserts a tab.
706 fn tab(&mut self, outdent: bool) {
707 let (a, b) = self.buf.selection().unwrap_or((self.buf.caret, self.buf.caret));
708 let is_list = |s: &Self, i: usize| matches!(preview::style_line(s.buf.line(i), s.ctx.get(i).copied().unwrap_or(Context::Normal), true).kind, Kind::List { .. });
709 if a.line == b.line && !is_list(self, a.line) && !outdent {
710 self.buf.insert("\t", EditKind::Other);
711 return;
712 }
713 let (caret, anchor) = (self.buf.caret, self.buf.anchor);
714 let mut caret_delta = 0isize;
715 let mut anchor_delta = 0isize;
716 for i in a.line..=b.line {
717 let text = self.buf.line(i).to_string();
718 let d: isize = if outdent {
719 let n = if text.starts_with('\t') {
720 1
721 } else {
722 text.chars().take(4).take_while(|c| *c == ' ').count()
723 };
724 if n == 0 {
725 continue;
726 }
727 self.buf.replace(Pos::new(i, 0), Pos::new(i, n), "", EditKind::Other);
728 -(n as isize)
729 } else {
730 self.buf.replace(Pos::new(i, 0), Pos::new(i, 0), "\t", EditKind::Other);
731 1
732 };
733 if i == caret.line {
734 caret_delta = d;
735 }
736 if anchor.is_some_and(|x| x.line == i) {
737 anchor_delta = d;
738 }
739 }
740 let fix = |p: Pos, d: isize| Pos::new(p.line, (p.col as isize + d).max(0) as usize);
741 self.buf.caret = self.buf.clamp(fix(caret, caret_delta));
742 self.buf.anchor = anchor.map(|x| self.buf.clamp(fix(x, anchor_delta)));
743 }
744
745 /// Wrap the selection in `mark` (Ctrl+B, Ctrl+I), or insert a pair
746 /// with the caret between.
747 fn wrap(&mut self, mark: &str) {
748 match self.buf.selection() {
749 Some((a, b)) if a.line == b.line => {
750 let inner = self.buf.text_range(a, b);
751 self.buf.replace(a, b, &format!("{mark}{inner}{mark}"), EditKind::Other);
752 self.buf.anchor = Some(Pos::new(a.line, a.col + mark.len()));
753 self.buf.caret = Pos::new(a.line, a.col + mark.len() + inner.len());
754 }
755 Some(_) => {}
756 None => {
757 let at = self.buf.caret;
758 self.buf.insert(&format!("{mark}{mark}"), EditKind::Other);
759 self.buf.caret = Pos::new(at.line, at.col + mark.len());
760 }
761 }
762 }
763
764 /// Replace `a..b` with `text` as one undo step and put the caret at
765 /// `caret` (a host's splice: a completion, a template).
766 pub fn edit(&mut self, a: Pos, b: Pos, text: &str, caret: Pos) {
767 self.buf.replace(a, b, text, EditKind::Other);
768 self.buf.anchor = None;
769 self.buf.caret = self.buf.clamp(caret);
770 self.want_x = None;
771 self.sync();
772 self.follow_caret = true;
773 }
774
775 pub fn undo(&mut self) -> bool {
776 let done = self.buf.undo();
777 if done {
778 self.sync();
779 self.follow_caret = true;
780 }
781 done
782 }
783
784 pub fn redo(&mut self) -> bool {
785 let done = self.buf.redo();
786 if done {
787 self.sync();
788 self.follow_caret = true;
789 }
790 done
791 }
792
793 /// A left press at a screen point. A rendered link follows; a task's
794 /// box toggles; Ctrl+click follows a raw link; otherwise the caret
795 /// moves (Shift extends; a double press selects a word, a triple the
796 /// line).
797 pub fn press(&mut self, sx: f32, sy: f32, shift: bool, ctrl: bool) -> Response {
798 self.sync();
799 let rev = self.buf.revision;
800 let before = (self.buf.caret, self.buf.anchor);
801 // A press mid-composition places the caret, read through the line
802 // as it is drawn, and the composition is left there, cancelled.
803 if self.composition.is_some() {
804 let p = self.pos_at(sx, sy);
805 self.drop_composition();
806 self.buf.set_caret(p, false);
807 self.dragging = true;
808 self.want_x = None;
809 return self.after_edit(rev, before);
810 }
811 let (x, y) = (sx - self.origin.0, sy - self.origin.1 + self.scroll);
812 if y >= 0.0 && !shift {
813 let i = self.line_at_y(y);
814 self.ensure(i);
815 self.retop();
816 let active = self.is_active(i, self.shown_active);
817 let l = self.layouts[i].as_ref().unwrap();
818 let ly = y - self.tops[i];
819 if let Some((r, at)) = l.task {
820 if r.width > 0.0 && x >= r.x && x <= r.x + r.width && ly >= r.y && ly <= r.y + r.height {
821 let status = self.buf.line(i)[at..].chars().next().unwrap_or(' ');
822 let next = if status == ' ' { "x" } else { " " };
823 let end = at + status.len_utf8();
824 let keep = (self.buf.caret, self.buf.anchor);
825 self.buf.replace(Pos::new(i, at), Pos::new(i, end), next, EditKind::Other);
826 (self.buf.caret, self.buf.anchor) = keep;
827 return self.after_edit(rev, before);
828 }
829 }
830 if let Some((r, bytes, on)) = l.toggle.clone() {
831 if x >= r.x && x <= r.x + r.width && ly >= r.y && ly <= r.y + r.height {
832 // A boolean property's box: flip it in place.
833 let keep = (self.buf.caret, self.buf.anchor);
834 let next = if on { "false" } else { "true" };
835 self.buf.replace(Pos::new(i, bytes.start), Pos::new(i, bytes.end), next, EditKind::Other);
836 (self.buf.caret, self.buf.anchor) = keep;
837 return self.after_edit(rev, before);
838 }
839 }
840 if !active || ctrl {
841 if let Some(k) = l.link_at(x, ly) {
842 return Response::Follow(l.links[k].clone());
843 }
844 }
845 }
846 let p = self.pos_at(sx, sy);
847 let now = Instant::now();
848 let count = match self.clicks {
849 Some((t, at, n)) if now.duration_since(t) < DOUBLE_CLICK && at == p => n % 3 + 1,
850 _ => 1,
851 };
852 self.clicks = Some((now, p, count));
853 match count {
854 2 => {
855 let (a, b) = self.buf.word_at(p);
856 self.buf.set_caret(a, false);
857 self.buf.set_caret(b, true);
858 }
859 3 => {
860 self.buf.set_caret(Pos::new(p.line, 0), false);
861 self.buf.set_caret(Pos::new(p.line, self.buf.line(p.line).len()), true);
862 }
863 _ => self.buf.set_caret(p, shift),
864 }
865 self.dragging = true;
866 self.want_x = None;
867 self.after_edit(rev, before)
868 }
869
870 /// Pointer motion; extends the selection while a press is held.
871 pub fn drag(&mut self, sx: f32, sy: f32) -> Response {
872 if !self.dragging {
873 return Response::None;
874 }
875 let before = (self.buf.caret, self.buf.anchor);
876 let p = self.pos_at(sx, sy);
877 if p != self.buf.caret {
878 self.buf.set_caret(p, true);
879 }
880 let r = self.after_edit(self.buf.revision, before);
881 // Selecting by drag does not drag the view to the caret: only an
882 // edge does, and the wheel.
883 self.follow_caret = sy < self.viewport.y || sy > self.viewport.y + self.viewport.height;
884 r
885 }
886
887 pub fn release(&mut self) {
888 self.dragging = false;
889 }
890
891 /// Whether a press is being dragged (the host keeps routing motion).
892 pub fn dragging(&self) -> bool {
893 self.dragging
894 }
895
896 /// A link under a screen point, for a hover cursor.
897 pub fn link_at(&mut self, sx: f32, sy: f32) -> bool {
898 let (x, y) = (sx - self.origin.0, sy - self.origin.1 + self.scroll);
899 if y < 0.0 || self.layouts.is_empty() {
900 return false;
901 }
902 let i = self.line_at_y(y);
903 if self.is_active(i, self.shown_active) {
904 return false;
905 }
906 self.ensure(i);
907 self.retop();
908 let l = self.layouts[i].as_ref().unwrap();
909 l.link_at(x, y - self.tops[i]).is_some() || l.task.is_some_and(|(r, _)| r.width > 0.0 && x >= r.x && x <= r.x + r.width && y - self.tops[i] <= r.y + r.height)
910 }
911
912 pub fn wheel(&mut self, delta: &MouseScrollDelta) -> bool {
913 let line = (self.theme.size * self.theme.spacing).max(1.0);
914 self.motion.reconcile(0.0, self.scroll);
915 let moved = self.motion.apply(delta, (line, line * 2.0), Bounds::max(0.0), Bounds::max(self.max_scroll()));
916 self.scroll = self.motion.y.pos();
917 moved
918 }
919
920 /// Advance a wheel glide; true while it moves.
921 pub fn tick(&mut self, dt: f32) -> bool {
922 if !self.motion.is_animating() {
923 return false;
924 }
925 self.motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
926 self.scroll = self.motion.y.pos();
927 true
928 }
929
930 /// Bring a line to the top of the view (search hits, outline clicks).
931 pub fn reveal_line(&mut self, line: usize) {
932 self.sync();
933 let line = line.min(self.buf.line_count() - 1);
934 self.retop();
935 self.scroll = self.tops[line].clamp(0.0, self.max_scroll());
936 self.motion.y.jump_to(self.scroll);
937 self.buf.set_caret(Pos::new(line, 0), false);
938 self.sync();
939 }
940
941 // ---- painting -------------------------------------------------------------
942
943 /// Paint into `rect`: lay out what shows, then selection, text, caret.
944 /// `focused` draws the caret.
945 pub fn paint(&mut self, pc: &mut PaintCtx, rect: Rect, focused: bool) {
946 self.prepare(rect);
947 self.paint_prepared(pc, focused);
948 }
949
950 /// Lay out what shows in `rect` and settle the scroll, without
951 /// painting: after it, [`DocEditor::caret_rect`] is where the caret
952 /// will be drawn (a popup placed before the paint needs that).
953 pub fn prepare(&mut self, rect: Rect) {
954 let mut width = (rect.width - 2.0 * self.pad).max(40.0);
955 if self.max_width > 0.0 {
956 width = width.min(self.max_width);
957 }
958 if (width - self.width).abs() > 0.5 {
959 self.width = width;
960 self.invalidate();
961 for i in 0..self.heights.len() {
962 self.heights[i] = 0.0;
963 }
964 }
965 self.viewport = rect;
966 self.origin = (rect.x + (rect.width - width) / 2.0, rect.y + self.pad);
967 self.sync();
968 self.sync_ime();
969 if std::mem::take(&mut self.follow_caret) {
970 self.scroll_to_caret();
971 }
972 // Lay out what shows (heights settle), then paint it.
973 let view_h = rect.height;
974 for _ in 0..2 {
975 let mut i = self.line_at_y(self.scroll - self.pad);
976 while i < self.buf.line_count() && self.tops[i] < self.scroll + view_h {
977 self.ensure(i);
978 i += 1;
979 }
980 self.retop();
981 }
982 self.scroll = self.scroll.clamp(0.0, self.max_scroll());
983 }
984
985 /// Paint what the last [`DocEditor::prepare`] laid out.
986 pub fn paint_prepared(&mut self, pc: &mut PaintCtx, focused: bool) {
987 self.paint_prepared_with(pc, focused, &|_| true);
988 }
989
990 /// [`DocEditor::paint_prepared`], with `resolved` saying whether a
991 /// link has a target: one that does not draws faded, as in Obsidian.
992 /// Asked at paint, so a vault change needs no relayout.
993 pub fn paint_prepared_with(&mut self, pc: &mut PaintCtx, focused: bool, resolved: &dyn Fn(&Target) -> bool) {
994 let rect = self.viewport;
995 let view_h = rect.height;
996 let (ox, oy) = (self.origin.0, self.origin.1 - self.scroll);
997 let sel = self.buf.selection();
998 let caret = self.buf.caret;
999 let caret_col = self.laid_col(caret.line, caret.col, true);
1000 let composition = self.composition_on(caret.line);
1001 let th = self.theme.clone();
1002 // Focused is typing: the on-screen keyboard follows
1003 // (`crate::text_input`). The viewport stands in until the caret's
1004 // line is drawn below, and stays when it is scrolled out of view.
1005 if focused {
1006 let (dx, dy) = pc.offset();
1007 crate::text_input::claim(rect.x + dx, rect.y + dy, rect.width, rect.height);
1008 }
1009 let first = self.line_at_y(self.scroll - self.pad);
1010 pc.clip(rect, |pc| {
1011 let mut i = first;
1012 while i < self.buf.line_count() && self.tops[i] < self.scroll + view_h {
1013 let Some(l) = self.layouts[i].as_ref() else {
1014 i += 1;
1015 continue;
1016 };
1017 let top = oy + self.tops[i];
1018 // Selection, behind everything on the line.
1019 // The boxes of the selected clusters, so a selection crossing a change of
1020 // direction is drawn where its letters are.
1021 if let Some((a, b)) = sel {
1022 if i >= a.line && i <= b.line {
1023 let from = if i == a.line { a.col } else { 0 };
1024 let to = if i == b.line { b.col } else { usize::MAX };
1025 for (row, x0, x1) in l.selection_rects(from, to, i < b.line) {
1026 if x1 > x0 {
1027 pc.quad(Rect { x: ox + x0, y: top + row as f32 * l.row_h, width: x1 - x0, height: l.row_h }, th.selection);
1028 }
1029 }
1030 }
1031 }
1032 for d in &l.decos {
1033 match d {
1034 Deco::Quad(r, c) => pc.quad(Rect { x: ox + r.x, y: top + r.y, width: r.width, height: r.height }, *c),
1035 Deco::Dot { cx, cy, r, color } => pc.circle(ox + cx, top + cy, *r, *color),
1036 Deco::Check { cx, cy, r, checked } => crate::widget::Checkbox::paint_inline(pc, ox + cx, top + cy, *r, *checked),
1037 Deco::Text { text, x, y, size, color, font } => {
1038 pc.text_with(text.clone(), ox + x, top + y, *size, srgb_u8(*color), Some(font.clone()), None)
1039 }
1040 Deco::Image { target, rect: r } => {
1041 let at = Rect { x: ox + r.x, y: top + r.y, width: r.width, height: r.height };
1042 match self.images.as_ref().and_then(|f| f(target)) {
1043 Some(img) => pc.image(img.id, at, 1.0),
1044 None => pc.rounded_rect(at, 4.0, (true, true, true, true), th.code_bg),
1045 }
1046 }
1047 }
1048 }
1049 for r in &l.runs {
1050 let row_y = top + r.row as f32 * l.row_h;
1051 if let Some(bg) = r.bg {
1052 let (pad, radius) = if r.look.pill { (layout::PILL_PAD, (l.row_h - 4.0) / 2.0) } else { (2.0, 3.0) };
1053 pc.rounded_rect(Rect { x: ox + r.x - pad, y: row_y + 2.0, width: r.w + 2.0 * pad, height: l.row_h - 4.0 }, radius, (true, true, true, true), bg);
1054 }
1055 let ty = row_y + (l.row_h - r.size) / 2.0;
1056 let color = match r.link {
1057 Some(k) if !resolved(&l.links[k]) => th.link_unresolved,
1058 _ => r.color,
1059 };
1060 pc.text_attrs(r.text.clone(), ox + r.x, ty, r.size, srgb_u8(color), Some(r.font.clone()), None, r.attrs);
1061 if r.strike {
1062 let sy = ty + r.size * 0.55;
1063 pc.vector(ox + r.x, sy, ox + r.x + r.w, sy, 1.0, r.color, Cap::Flat);
1064 }
1065 }
1066 if let Some((c, len, _)) = composition {
1067 if i == caret.line {
1068 // The composition's underline, a row at a time.
1069 let (from, to) = (l.caret_xy(c), l.caret_xy(c + len));
1070 for row in from.1..=to.1 {
1071 let x0 = if row == from.1 { from.0 } else { l.content_x };
1072 let x1 = if row == to.1 { to.0 } else { l.runs.iter().filter(|r| r.row == row).map(|r| r.x + r.w).fold(x0, f32::max) };
1073 if x1 > x0 {
1074 let uy = top + (row + 1) as f32 * l.row_h - 3.0;
1075 pc.quad(Rect { x: ox + x0, y: uy, width: x1 - x0, height: 1.5 }, th.caret);
1076 }
1077 }
1078 }
1079 }
1080 if focused && i == caret.line {
1081 let (x, row) = l.caret_xy(caret_col);
1082 let h = l.row_h * 0.8;
1083 let y = top + row as f32 * l.row_h + (l.row_h - h) / 2.0;
1084 pc.quad(Rect { x: ox + x - 0.5, y, width: 2.0, height: h }, th.caret);
1085 // Where an input method puts its candidates, and that
1086 // text is wanted at all.
1087 let (dx, dy) = pc.offset();
1088 crate::ime::report_caret(ox + x - 0.5 + dx, y + dy, 2.0, h);
1089 }
1090 i += 1;
1091 }
1092 });
1093 }
1094 }
1095
1096 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
1097 let s = crate::colors::to_srgb(linear);
1098 [(s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8]
1099 }
1100
1101 #[cfg(test)]
1102 mod tests {
1103 use super::*;
1104 use crate::widget::ElementState;
1105
1106 fn key(k: Key, text: Option<&str>, ctrl: bool, shift: bool) -> KeyEvent {
1107 KeyEvent { state: ElementState::Pressed, logical_key: k, text: text.map(String::from), repeat: false, ctrl, shift, alt: false }
1108 }
1109
1110 fn typed(c: &str) -> KeyEvent {
1111 key(Key::Character(c.into()), Some(c), false, false)
1112 }
1113
1114 fn named(n: NamedKey) -> KeyEvent {
1115 key(Key::Named(n), None, false, false)
1116 }
1117
1118 fn editor(text: &str) -> DocEditor {
1119 let mut e = DocEditor::new(text, EditorTheme::new(14.0), false);
1120 let mut pc = PaintCtx::new();
1121 e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
1122 e
1123 }
1124
1125 /// A composition is in the caret line's layout and never in the buffer:
1126 /// drawn as typed and underlined, the caret where the input method has
1127 /// its cursor, columns read off the line mapped across it; keys wait
1128 /// while it is up; the commit is typed; a press drops it, asks the input
1129 /// method to cancel, and places the caret; the caret is reported.
1130 #[test]
1131 fn a_composition_is_laid_out_in_place_and_never_held() {
1132 use crate::ime::{self, Preedit};
1133 let rect = Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 };
1134 let mut e = editor("ab\ncd");
1135 e.buf.caret = Pos::new(0, 1);
1136 e.buf.anchor = None;
1137 let mut pc = PaintCtx::new();
1138 e.paint(&mut pc, rect, true);
1139 let plain_caret = e.caret_rect().x;
1140 let _ = ime::take_reset();
1141
1142 ime::set_preedit(Some(Preedit::new("にほ", Some((3, 3)))));
1143 ime::begin_frame();
1144 e.paint(&mut pc, rect, true);
1145 ime::end_frame();
1146 assert_eq!(e.text(), "ab\ncd", "never in the buffer");
1147 let l = e.layouts[0].as_ref().unwrap();
1148 let drawn: String = l.runs.iter().map(|r| r.text.as_str()).collect();
1149 assert_eq!(drawn, "aにほb");
1150 assert!(e.caret_rect().x > plain_caret, "the caret is after に");
1151 assert!(ime::caret().is_some(), "the caret is reported");
1152 // Past the composition on its line is the source's end of line.
1153 assert_eq!(e.pos_at(rect.x + 590.0, rect.y + e.pad + 2.0), Pos::new(0, 2));
1154 assert_eq!(e.key(&typed("x")), Response::None);
1155 assert_eq!(e.text(), "ab\ncd");
1156
1157 // The commit: the composition ends, its text is typed.
1158 ime::set_preedit(None);
1159 assert_eq!(e.key(&typed("日本")), Response::Changed);
1160 assert_eq!(e.text(), "a日本b\ncd");
1161 assert_eq!(e.buf.caret, Pos::new(0, 7));
1162
1163 // A press drops a composition, asks for it to be cancelled, and
1164 // places the caret.
1165 ime::set_preedit(Some(Preedit::new("か", None)));
1166 e.paint(&mut pc, rect, true);
1167 assert!(e.composing());
1168 e.press(rect.x + 590.0, rect.y + e.pad + 2.0, false, false);
1169 e.release();
1170 assert!(!e.composing());
1171 assert!(ime::take_reset());
1172 assert_eq!(e.text(), "a日本b\ncd");
1173 assert_eq!(e.buf.caret, Pos::new(0, "a日本b".len()));
1174 }
1175
1176 #[test]
1177 fn an_embed_line_shows_its_image_and_the_raw_text_with_the_caret() {
1178 let mut e = DocEditor::new("top\n![[pic.png|200]]\nend", EditorTheme::new(14.0), false);
1179 e.set_images(Box::new(|t: &str| (t == "pic.png").then_some(EmbedImage { id: 3, width: 800, height: 400 })));
1180 let rect = Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 };
1181 let mut pc = PaintCtx::new();
1182 e.paint(&mut pc, rect, true);
1183 let l = e.layouts[1].as_ref().unwrap();
1184 assert!(l.runs.is_empty(), "inactive: the image alone");
1185 assert!(matches!(&l.decos[0], Deco::Image { rect, .. } if rect.width == 200.0 && rect.height == 100.0));
1186 assert_eq!(l.height, 100.0 + 2.0 * layout::IMAGE_PAD);
1187 // The caret on it: raw text, image below.
1188 e.buf.caret = Pos::new(1, 0);
1189 e.buf.anchor = None;
1190 e.paint(&mut pc, rect, true);
1191 let l = e.layouts[1].as_ref().unwrap();
1192 assert!(!l.runs.is_empty());
1193 assert!(l.decos.iter().any(|d| matches!(d, Deco::Image { .. })));
1194 // Source mode: just text.
1195 e.set_preview(false);
1196 e.paint(&mut pc, rect, true);
1197 assert!(!e.layouts[1].as_ref().unwrap().decos.iter().any(|d| matches!(d, Deco::Image { .. })));
1198 }
1199
1200 #[test]
1201 fn embeds_inside_a_line_show_below_it() {
1202 let mut e = DocEditor::new("text ![[a.png|100]] and ![[b.png|100]] more\nend", EditorTheme::new(14.0), false);
1203 e.set_images(Box::new(|_| Some(EmbedImage { id: 3, width: 200, height: 100 })));
1204 let mut pc = PaintCtx::new();
1205 e.buf.caret = Pos::new(1, 0);
1206 e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
1207 let l = e.layouts[0].as_ref().unwrap();
1208 assert!(!l.runs.is_empty(), "the text stays");
1209 let rects: Vec<Rect> = l.decos.iter().filter_map(|d| match d {
1210 Deco::Image { rect, .. } => Some(*rect),
1211 _ => None,
1212 }).collect();
1213 assert_eq!(rects.len(), 2);
1214 assert_eq!((rects[0].width, rects[0].height), (100.0, 50.0));
1215 assert!(rects[1].x > rects[0].x && rects[1].y == rects[0].y, "side by side");
1216 assert!(l.height >= l.row_h + 50.0);
1217 }
1218
1219 #[test]
1220 fn typing_enter_and_list_continuation() {
1221 let mut e = editor("");
1222 for c in ["-", " ", "[", " ", "]", " ", "a"] {
1223 e.key(&typed(c));
1224 }
1225 assert_eq!(e.key(&named(NamedKey::Enter)), Response::Changed);
1226 e.key(&typed("b"));
1227 assert_eq!(e.text(), "- [ ] a\n- [ ] b");
1228 e.key(&named(NamedKey::Enter));
1229 // Enter on an empty item ends the list.
1230 e.key(&named(NamedKey::Enter));
1231 assert_eq!(e.text(), "- [ ] a\n- [ ] b\n");
1232 let mut e = editor("3. three");
1233 e.buf.caret = Pos::new(0, 8);
1234 e.key(&named(NamedKey::Enter));
1235 assert_eq!(e.text(), "3. three\n4. ");
1236 }
1237
1238 #[test]
1239 fn tab_indents_list_items_and_undo_works() {
1240 let mut e = editor("- a\n- b");
1241 e.buf.caret = Pos::new(1, 3);
1242 e.key(&named(NamedKey::Tab));
1243 assert_eq!(e.text(), "- a\n\t- b");
1244 assert_eq!(e.buf.caret, Pos::new(1, 4));
1245 e.key(&key(Key::Named(NamedKey::Tab), None, false, true));
1246 assert_eq!(e.text(), "- a\n- b");
1247 assert!(e.undo());
1248 assert_eq!(e.text(), "- a\n\t- b");
1249 }
1250
1251 #[test]
1252 fn arrows_words_home_end_and_vertical() {
1253 let mut e = editor("- [ ] first item\nsecond");
1254 e.buf.caret = Pos::new(0, 10);
1255 e.key(&named(NamedKey::Home));
1256 assert_eq!(e.buf.caret, Pos::new(0, 6), "smart home: the content start");
1257 e.key(&named(NamedKey::Home));
1258 assert_eq!(e.buf.caret, Pos::new(0, 0));
1259 e.key(&named(NamedKey::End));
1260 assert_eq!(e.buf.caret, Pos::new(0, 16));
1261 e.key(&named(NamedKey::ArrowDown));
1262 assert_eq!(e.buf.caret.line, 1);
1263 e.key(&key(Key::Named(NamedKey::ArrowLeft), None, true, true));
1264 assert_eq!(e.buf.selected_text().as_deref(), Some("second"));
1265 }
1266
1267 #[test]
1268 fn clicks_place_the_caret_and_toggle_tasks() {
1269 let mut e = editor("- [ ] todo\nplain line");
1270 // Line 0 is not active (the caret is on it? it starts at 0,0 — move it).
1271 e.buf.caret = Pos::new(1, 0);
1272 let mut pc = PaintCtx::new();
1273 e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
1274 let l = e.layouts[0].as_ref().unwrap();
1275 let (r, _) = l.task.unwrap();
1276 let (sx, sy) = (e.origin.0 + r.x + r.width / 2.0, e.origin.1 + r.y + r.height / 2.0);
1277 assert_eq!(e.press(sx, sy, false, false), Response::Changed);
1278 assert_eq!(e.text(), "- [x] todo\nplain line");
1279 assert_eq!(e.buf.caret, Pos::new(1, 0), "ticking does not move the caret");
1280 // A click at the far left of the second line puts the caret there.
1281 let y1 = e.origin.1 + e.tops[1] + 3.0;
1282 e.release();
1283 e.press(e.origin.0 + 1.0, y1, false, false);
1284 assert_eq!(e.buf.caret, Pos::new(1, 0));
1285 }
1286
1287 #[test]
1288 fn rendered_links_follow() {
1289 let mut e = editor("see [[Target|it]] now\nsecond");
1290 e.buf.caret = Pos::new(1, 0);
1291 let mut pc = PaintCtx::new();
1292 e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
1293 let l = e.layouts[0].as_ref().unwrap();
1294 let run = l.runs.iter().find(|r| r.link.is_some()).unwrap();
1295 let (sx, sy) = (e.origin.0 + run.x + 2.0, e.origin.1 + 4.0);
1296 assert_eq!(e.press(sx, sy, false, false), Response::Follow(Target::Note { target: "Target".into(), subpath: None }));
1297 }
1298
1299 #[test]
1300 fn the_frontmatter_is_a_table_until_the_caret_enters_it() {
1301 let mut e = editor("---\ndone: false\ntags: [x]\n---\nbody");
1302 e.buf.caret = Pos::new(4, 0);
1303 let mut pc = PaintCtx::new();
1304 e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
1305 let header = e.layouts[0].as_ref().unwrap();
1306 assert!(matches!(&header.decos[0], Deco::Text { text, .. } if text == "Properties"));
1307 let row = e.layouts[1].as_ref().unwrap();
1308 let (r, _, on) = row.toggle.clone().expect("a checkbox");
1309 assert!(!on);
1310 // Ticking the box writes `true`, and the caret stays put.
1311 let (sx, sy) = (e.origin.0 + r.x + r.width / 2.0, e.origin.1 + e.tops[1] + r.y + r.height / 2.0);
1312 assert_eq!(e.press(sx, sy, false, false), Response::Changed);
1313 assert_eq!(e.text(), "---\ndone: true\ntags: [x]\n---\nbody");
1314 assert_eq!(e.buf.caret, Pos::new(4, 0));
1315 e.release();
1316 // The pill is padded inside the value column.
1317 e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
1318 let tags = e.layouts[2].as_ref().unwrap();
1319 assert_eq!(tags.runs.len(), 1);
1320 assert!(tags.runs[0].look.pill && tags.runs[0].x >= tags.content_x + layout::PILL_PAD - 0.01);
1321 // The caret in the block shows all of it raw.
1322 e.buf.set_caret(Pos::new(2, 0), false);
1323 e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
1324 for i in 0..4 {
1325 let l = e.layouts[i].as_ref().unwrap();
1326 assert_eq!(l.runs.iter().map(|r| r.text.as_str()).collect::<String>(), e.buf.line(i), "line {i} raw");
1327 }
1328 }
1329
1330 #[test]
1331 fn a_long_document_shapes_only_what_shows() {
1332 let text: String = (0..5000).map(|i| format!("line {i} with **some** text\n")).collect();
1333 let e = editor(&text);
1334 let laid = e.layouts.iter().filter(|l| l.is_some()).count();
1335 assert!(laid < 60, "{laid} lines shaped");
1336 assert!(e.content_height() > 5000.0 * 14.0);
1337 }
1338 }