Personal outliner built with Rust.
4

Configure Feed

Select the types of activity you want to include in your feed.

phase 5.6: reference completion ([[ / # popup with date shortcuts, cursor-anchored, deferred overlay rendering)

graham.systems (Jul 12, 2026, 2:52 PM -0700) 315c4d52 8827cadf

+1073 -752
+1
Cargo.lock
··· 6936 6936 name = "trawler" 6937 6937 version = "0.1.0" 6938 6938 dependencies = [ 6939 + "chrono", 6939 6940 "gpui", 6940 6941 "loro", 6941 6942 "pulldown-cmark",
+1
crates/trawler/Cargo.toml
··· 8 8 workspace = true 9 9 10 10 [dependencies] 11 + chrono = "0.4.45" 11 12 gpui = "0.2.2" 12 13 loro = "1.13.6" 13 14 pulldown-cmark = { version = "0.13.4", default-features = false }
+988 -750
crates/trawler/src/editor.rs
··· 1 - //! A minimal multi-line text editor built directly on GPUI's raw 2 - //! `EntityInputHandler`/`Element` primitives — no `gpui-component`. 3 - //! 4 - //! `gpui-component`'s `Input` came with its own opinionated keybindings for 5 - //! Enter/Tab/Backspace baked in, which fought every attempt to give those 6 - //! keys outline semantics (split/indent/merge) instead of textarea 7 - //! semantics, plus a default-width bug that rendered the one-hot editor as 8 - //! a collapsed sliver. This module owns 100% of the block editor's 9 - //! behavior instead, modeled closely on gpui's own `examples/input.rs` 10 - //! (single-line) extended to multiple lines split on `\n` — no soft 11 - //! word-wrap, matching a plain source-text editing surface. 12 - //! 13 - //! `BlockEditor` only edits text and reports high-level outline intents 14 - //! (split/merge/indent/outdent/blur) via `BlockEditorEvent`; it knows 15 - //! nothing about the graph. The caller (`TrawlerApp`) owns what those 16 - //! intents actually do. 17 - 18 - use std::ops::Range; 19 - 20 - use gpui::{ 21 - actions, div, fill, point, px, relative, rgba, size, App, Bounds, ClipboardItem, Context, 22 - CursorStyle, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, EventEmitter, 23 - FocusHandle, Focusable, GlobalElementId, InteractiveElement, KeyBinding, LayoutId, MouseButton, 24 - MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, Point, Render, 25 - ShapedLine, SharedString, Style, Styled, Subscription, TextRun, UTF16Selection, Window, 26 - }; 27 - use unicode_segmentation::UnicodeSegmentation; 28 - 29 - pub const CONTEXT: &str = "BlockEditor"; 30 - 31 - actions!( 32 - block_editor, 33 - [ 34 - EditorMoveLeft, 35 - EditorMoveRight, 36 - EditorMoveUp, 37 - EditorMoveDown, 38 - EditorSelectLeft, 39 - EditorSelectRight, 40 - EditorSelectAll, 41 - EditorHome, 42 - EditorEnd, 43 - EditorBackspace, 44 - EditorDelete, 45 - EditorEnter, 46 - EditorShiftEnter, 47 - EditorTab, 48 - EditorShiftTab, 49 - EditorMoveBlockUp, 50 - EditorMoveBlockDown, 51 - EditorPaste, 52 - EditorCut, 53 - EditorCopy, 54 - ] 55 - ); 56 - 57 - pub fn init(cx: &mut App) { 58 - cx.bind_keys([ 59 - KeyBinding::new("left", EditorMoveLeft, Some(CONTEXT)), 60 - KeyBinding::new("right", EditorMoveRight, Some(CONTEXT)), 61 - KeyBinding::new("up", EditorMoveUp, Some(CONTEXT)), 62 - KeyBinding::new("down", EditorMoveDown, Some(CONTEXT)), 63 - KeyBinding::new("shift-left", EditorSelectLeft, Some(CONTEXT)), 64 - KeyBinding::new("shift-right", EditorSelectRight, Some(CONTEXT)), 65 - KeyBinding::new("ctrl-a", EditorSelectAll, Some(CONTEXT)), 66 - KeyBinding::new("home", EditorHome, Some(CONTEXT)), 67 - KeyBinding::new("end", EditorEnd, Some(CONTEXT)), 68 - KeyBinding::new("backspace", EditorBackspace, Some(CONTEXT)), 69 - KeyBinding::new("delete", EditorDelete, Some(CONTEXT)), 70 - KeyBinding::new("enter", EditorEnter, Some(CONTEXT)), 71 - KeyBinding::new("shift-enter", EditorShiftEnter, Some(CONTEXT)), 72 - KeyBinding::new("tab", EditorTab, Some(CONTEXT)), 73 - KeyBinding::new("shift-tab", EditorShiftTab, Some(CONTEXT)), 74 - KeyBinding::new("alt-up", EditorMoveBlockUp, Some(CONTEXT)), 75 - KeyBinding::new("alt-down", EditorMoveBlockDown, Some(CONTEXT)), 76 - KeyBinding::new("ctrl-v", EditorPaste, Some(CONTEXT)), 77 - KeyBinding::new("ctrl-c", EditorCopy, Some(CONTEXT)), 78 - KeyBinding::new("ctrl-x", EditorCut, Some(CONTEXT)), 79 - ]); 80 - } 81 - 82 - /// High-level intents `BlockEditor` reports to its owner. The owner (not 83 - /// this module) knows what "split the block" or "merge with previous" 84 - /// means in graph terms. 85 - #[derive(Debug, Clone, Copy)] 86 - pub enum BlockEditorEvent { 87 - Blurred, 88 - /// Plain Enter: split into a new sibling block at the cursor (spec: 89 - /// outline-editor "Enter creates a new sibling block"). 90 - SplitRequested, 91 - /// Backspace with an empty selection at content-start: merge into the 92 - /// previous sibling. 93 - MergeWithPreviousRequested, 94 - Indent, 95 - Outdent, 96 - MoveBlockUp, 97 - MoveBlockDown, 98 - /// Up pressed with the cursor already on the block's first line: hand 99 - /// focus to the previous visible block. 100 - FocusPreviousBlock, 101 - /// Down pressed with the cursor already on the block's last line: hand 102 - /// focus to the next visible block. 103 - FocusNextBlock, 104 - } 105 - 106 - pub struct BlockEditor { 107 - focus_handle: FocusHandle, 108 - content: String, 109 - selected_range: Range<usize>, 110 - selection_reversed: bool, 111 - marked_range: Option<Range<usize>>, 112 - last_layout: Vec<ShapedLine>, 113 - last_bounds: Option<Bounds<Pixels>>, 114 - is_selecting: bool, 115 - _blur_subscription: Subscription, 116 - } 117 - 118 - impl EventEmitter<BlockEditorEvent> for BlockEditor {} 119 - 120 - impl Focusable for BlockEditor { 121 - fn focus_handle(&self, _: &App) -> FocusHandle { 122 - self.focus_handle.clone() 123 - } 124 - } 125 - 126 - impl BlockEditor { 127 - pub fn new(window: &mut Window, cx: &mut Context<Self>, content: String) -> Self { 128 - let len = content.len(); 129 - let focus_handle = cx.focus_handle(); 130 - let blur_subscription = cx.on_blur(&focus_handle, window, |_this, _window, cx| { 131 - cx.emit(BlockEditorEvent::Blurred); 132 - }); 133 - Self { 134 - focus_handle, 135 - content, 136 - selected_range: len..len, 137 - selection_reversed: false, 138 - marked_range: None, 139 - last_layout: Vec::new(), 140 - last_bounds: None, 141 - is_selecting: false, 142 - _blur_subscription: blur_subscription, 143 - } 144 - } 145 - 146 - pub fn value(&self) -> &str { 147 - &self.content 148 - } 149 - 150 - pub fn cursor_offset(&self) -> usize { 151 - if self.selection_reversed { 152 - self.selected_range.start 153 - } else { 154 - self.selected_range.end 155 - } 156 - } 157 - 158 - fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) { 159 - self.selected_range = offset..offset; 160 - cx.notify(); 161 - } 162 - 163 - fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) { 164 - if self.selection_reversed { 165 - self.selected_range.start = offset; 166 - } else { 167 - self.selected_range.end = offset; 168 - } 169 - if self.selected_range.end < self.selected_range.start { 170 - self.selection_reversed = !self.selection_reversed; 171 - self.selected_range = self.selected_range.end..self.selected_range.start; 172 - } 173 - cx.notify(); 174 - } 175 - 176 - fn previous_boundary(&self, offset: usize) -> usize { 177 - self.content 178 - .grapheme_indices(true) 179 - .rev() 180 - .find_map(|(idx, _)| (idx < offset).then_some(idx)) 181 - .unwrap_or(0) 182 - } 183 - 184 - fn next_boundary(&self, offset: usize) -> usize { 185 - self.content 186 - .grapheme_indices(true) 187 - .find_map(|(idx, _)| (idx > offset).then_some(idx)) 188 - .unwrap_or(self.content.len()) 189 - } 190 - 191 - /// Byte range of the line containing `offset` (excluding its `\n`). 192 - fn line_bounds(&self, offset: usize) -> Range<usize> { 193 - let start = self.content[..offset].rfind('\n').map_or(0, |i| i + 1); 194 - let end = self.content[offset..] 195 - .find('\n') 196 - .map_or(self.content.len(), |i| offset + i); 197 - start..end 198 - } 199 - 200 - fn move_left(&mut self, _: &EditorMoveLeft, _: &mut Window, cx: &mut Context<Self>) { 201 - if self.selected_range.is_empty() { 202 - self.move_to(self.previous_boundary(self.cursor_offset()), cx); 203 - } else { 204 - self.move_to(self.selected_range.start, cx); 205 - } 206 - } 207 - 208 - fn move_right(&mut self, _: &EditorMoveRight, _: &mut Window, cx: &mut Context<Self>) { 209 - if self.selected_range.is_empty() { 210 - self.move_to(self.next_boundary(self.cursor_offset()), cx); 211 - } else { 212 - self.move_to(self.selected_range.end, cx); 213 - } 214 - } 215 - 216 - /// Column-preserving move to the previous line (byte-offset-within-line 217 - /// approximation, not pixel-accurate for proportional fonts — a 218 - /// reasonable trade for source-text editing; see module doc). At the 219 - /// first line already, hand off to the previous block instead (spec 220 - /// scenario: "Focus transition commits content" — Up/Down move focus 221 - /// across blocks at a content boundary). 222 - fn move_up(&mut self, _: &EditorMoveUp, _: &mut Window, cx: &mut Context<Self>) { 223 - let cursor = self.cursor_offset(); 224 - let line = self.line_bounds(cursor); 225 - if line.start == 0 { 226 - cx.emit(BlockEditorEvent::FocusPreviousBlock); 227 - return; 228 - } 229 - let column = cursor - line.start; 230 - let prev_line = self.line_bounds(line.start - 1); 231 - let target = prev_line.start + column.min(prev_line.end - prev_line.start); 232 - self.move_to(self.snap_to_char_boundary(target), cx); 233 - } 234 - 235 - fn move_down(&mut self, _: &EditorMoveDown, _: &mut Window, cx: &mut Context<Self>) { 236 - let cursor = self.cursor_offset(); 237 - let line = self.line_bounds(cursor); 238 - if line.end == self.content.len() { 239 - cx.emit(BlockEditorEvent::FocusNextBlock); 240 - return; 241 - } 242 - let column = cursor - line.start; 243 - let next_line = self.line_bounds(line.end + 1); 244 - let target = next_line.start + column.min(next_line.end - next_line.start); 245 - self.move_to(self.snap_to_char_boundary(target), cx); 246 - } 247 - 248 - fn snap_to_char_boundary(&self, offset: usize) -> usize { 249 - let mut offset = offset.min(self.content.len()); 250 - while offset > 0 && !self.content.is_char_boundary(offset) { 251 - offset -= 1; 252 - } 253 - offset 254 - } 255 - 256 - fn select_left(&mut self, _: &EditorSelectLeft, _: &mut Window, cx: &mut Context<Self>) { 257 - self.select_to(self.previous_boundary(self.cursor_offset()), cx); 258 - } 259 - 260 - fn select_right(&mut self, _: &EditorSelectRight, _: &mut Window, cx: &mut Context<Self>) { 261 - self.select_to(self.next_boundary(self.cursor_offset()), cx); 262 - } 263 - 264 - fn select_all(&mut self, _: &EditorSelectAll, _: &mut Window, cx: &mut Context<Self>) { 265 - self.selected_range = 0..self.content.len(); 266 - cx.notify(); 267 - } 268 - 269 - fn home(&mut self, _: &EditorHome, _: &mut Window, cx: &mut Context<Self>) { 270 - let line = self.line_bounds(self.cursor_offset()); 271 - self.move_to(line.start, cx); 272 - } 273 - 274 - fn end(&mut self, _: &EditorEnd, _: &mut Window, cx: &mut Context<Self>) { 275 - let line = self.line_bounds(self.cursor_offset()); 276 - self.move_to(line.end, cx); 277 - } 278 - 279 - fn backspace(&mut self, _: &EditorBackspace, window: &mut Window, cx: &mut Context<Self>) { 280 - if self.selected_range.is_empty() && self.cursor_offset() == 0 { 281 - cx.emit(BlockEditorEvent::MergeWithPreviousRequested); 282 - return; 283 - } 284 - if self.selected_range.is_empty() { 285 - self.select_to(self.previous_boundary(self.cursor_offset()), cx); 286 - } 287 - self.replace_text_in_range(None, "", window, cx); 288 - } 289 - 290 - fn delete(&mut self, _: &EditorDelete, window: &mut Window, cx: &mut Context<Self>) { 291 - if self.selected_range.is_empty() { 292 - self.select_to(self.next_boundary(self.cursor_offset()), cx); 293 - } 294 - self.replace_text_in_range(None, "", window, cx); 295 - } 296 - 297 - fn enter(&mut self, _: &EditorEnter, _: &mut Window, cx: &mut Context<Self>) { 298 - cx.emit(BlockEditorEvent::SplitRequested); 299 - } 300 - 301 - fn shift_enter(&mut self, _: &EditorShiftEnter, window: &mut Window, cx: &mut Context<Self>) { 302 - self.replace_text_in_range(None, "\n", window, cx); 303 - } 304 - 305 - fn tab(&mut self, _: &EditorTab, _: &mut Window, cx: &mut Context<Self>) { 306 - cx.emit(BlockEditorEvent::Indent); 307 - } 308 - 309 - fn shift_tab(&mut self, _: &EditorShiftTab, _: &mut Window, cx: &mut Context<Self>) { 310 - cx.emit(BlockEditorEvent::Outdent); 311 - } 312 - 313 - fn move_block_up(&mut self, _: &EditorMoveBlockUp, _: &mut Window, cx: &mut Context<Self>) { 314 - cx.emit(BlockEditorEvent::MoveBlockUp); 315 - } 316 - 317 - fn move_block_down(&mut self, _: &EditorMoveBlockDown, _: &mut Window, cx: &mut Context<Self>) { 318 - cx.emit(BlockEditorEvent::MoveBlockDown); 319 - } 320 - 321 - fn paste(&mut self, _: &EditorPaste, window: &mut Window, cx: &mut Context<Self>) { 322 - if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { 323 - self.replace_text_in_range(None, &text, window, cx); 324 - } 325 - } 326 - 327 - fn copy(&mut self, _: &EditorCopy, _: &mut Window, cx: &mut Context<Self>) { 328 - if !self.selected_range.is_empty() { 329 - cx.write_to_clipboard(ClipboardItem::new_string( 330 - self.content[self.selected_range.clone()].to_string(), 331 - )); 332 - } 333 - } 334 - 335 - fn cut(&mut self, _: &EditorCut, window: &mut Window, cx: &mut Context<Self>) { 336 - if !self.selected_range.is_empty() { 337 - cx.write_to_clipboard(ClipboardItem::new_string( 338 - self.content[self.selected_range.clone()].to_string(), 339 - )); 340 - self.replace_text_in_range(None, "", window, cx); 341 - } 342 - } 343 - 344 - fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context<Self>) { 345 - self.is_selecting = true; 346 - let offset = self.index_for_position(event.position); 347 - if event.modifiers.shift { 348 - self.select_to(offset, cx); 349 - } else { 350 - self.move_to(offset, cx); 351 - } 352 - } 353 - 354 - fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) { 355 - self.is_selecting = false; 356 - } 357 - 358 - fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) { 359 - if self.is_selecting { 360 - let offset = self.index_for_position(event.position); 361 - self.select_to(offset, cx); 362 - } 363 - } 364 - 365 - fn index_for_position(&self, position: Point<Pixels>) -> usize { 366 - let Some(bounds) = self.last_bounds else { 367 - return 0; 368 - }; 369 - if self.last_layout.is_empty() { 370 - return 0; 371 - } 372 - let line_height = bounds_line_height(&self.last_layout); 373 - let relative_y = (position.y - bounds.top()).max(px(0.)); 374 - let line_ix = ((relative_y / line_height) as usize).min(self.last_layout.len() - 1); 375 - let line = &self.last_layout[line_ix]; 376 - let line_start = self.line_start_offset(line_ix); 377 - line_start + line.closest_index_for_x(position.x - bounds.left()) 378 - } 379 - 380 - fn line_start_offset(&self, line_ix: usize) -> usize { 381 - self.content 382 - .split('\n') 383 - .take(line_ix) 384 - .map(|l| l.len() + 1) 385 - .sum() 386 - } 387 - 388 - fn offset_from_utf16(&self, offset: usize) -> usize { 389 - let mut utf8_offset = 0; 390 - let mut utf16_count = 0; 391 - for ch in self.content.chars() { 392 - if utf16_count >= offset { 393 - break; 394 - } 395 - utf16_count += ch.len_utf16(); 396 - utf8_offset += ch.len_utf8(); 397 - } 398 - utf8_offset 399 - } 400 - 401 - fn offset_to_utf16(&self, offset: usize) -> usize { 402 - let mut utf16_offset = 0; 403 - let mut utf8_count = 0; 404 - for ch in self.content.chars() { 405 - if utf8_count >= offset { 406 - break; 407 - } 408 - utf8_count += ch.len_utf8(); 409 - utf16_offset += ch.len_utf16(); 410 - } 411 - utf16_offset 412 - } 413 - 414 - fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> { 415 - self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) 416 - } 417 - 418 - fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> { 419 - self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) 420 - } 421 - } 422 - 423 - fn bounds_line_height(lines: &[ShapedLine]) -> Pixels { 424 - lines 425 - .first() 426 - .map(|l| (l.ascent + l.descent).max(px(1.))) 427 - .unwrap_or(px(20.)) 428 - } 429 - 430 - impl EntityInputHandler for BlockEditor { 431 - fn text_for_range( 432 - &mut self, 433 - range_utf16: Range<usize>, 434 - actual_range: &mut Option<Range<usize>>, 435 - _window: &mut Window, 436 - _cx: &mut Context<Self>, 437 - ) -> Option<String> { 438 - let range = self.range_from_utf16(&range_utf16); 439 - actual_range.replace(self.range_to_utf16(&range)); 440 - Some(self.content[range].to_string()) 441 - } 442 - 443 - fn selected_text_range( 444 - &mut self, 445 - _ignore_disabled_input: bool, 446 - _window: &mut Window, 447 - _cx: &mut Context<Self>, 448 - ) -> Option<UTF16Selection> { 449 - Some(UTF16Selection { 450 - range: self.range_to_utf16(&self.selected_range), 451 - reversed: self.selection_reversed, 452 - }) 453 - } 454 - 455 - fn marked_text_range( 456 - &self, 457 - _window: &mut Window, 458 - _cx: &mut Context<Self>, 459 - ) -> Option<Range<usize>> { 460 - self.marked_range 461 - .as_ref() 462 - .map(|range| self.range_to_utf16(range)) 463 - } 464 - 465 - fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) { 466 - self.marked_range = None; 467 - } 468 - 469 - fn replace_text_in_range( 470 - &mut self, 471 - range_utf16: Option<Range<usize>>, 472 - new_text: &str, 473 - _: &mut Window, 474 - cx: &mut Context<Self>, 475 - ) { 476 - let range = range_utf16 477 - .as_ref() 478 - .map(|r| self.range_from_utf16(r)) 479 - .or(self.marked_range.clone()) 480 - .unwrap_or(self.selected_range.clone()); 481 - 482 - self.content = 483 - self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]; 484 - self.selected_range = range.start + new_text.len()..range.start + new_text.len(); 485 - self.marked_range.take(); 486 - cx.notify(); 487 - } 488 - 489 - fn replace_and_mark_text_in_range( 490 - &mut self, 491 - range_utf16: Option<Range<usize>>, 492 - new_text: &str, 493 - new_selected_range_utf16: Option<Range<usize>>, 494 - _window: &mut Window, 495 - cx: &mut Context<Self>, 496 - ) { 497 - let range = range_utf16 498 - .as_ref() 499 - .map(|r| self.range_from_utf16(r)) 500 - .or(self.marked_range.clone()) 501 - .unwrap_or(self.selected_range.clone()); 502 - 503 - self.content = 504 - self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]; 505 - self.marked_range = if new_text.is_empty() { 506 - None 507 - } else { 508 - Some(range.start..range.start + new_text.len()) 509 - }; 510 - self.selected_range = new_selected_range_utf16 511 - .as_ref() 512 - .map(|r| self.range_from_utf16(r)) 513 - .map(|r| r.start + range.start..r.end + range.start) 514 - .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); 515 - cx.notify(); 516 - } 517 - 518 - fn bounds_for_range( 519 - &mut self, 520 - range_utf16: Range<usize>, 521 - bounds: Bounds<Pixels>, 522 - _window: &mut Window, 523 - _cx: &mut Context<Self>, 524 - ) -> Option<Bounds<Pixels>> { 525 - let range = self.range_from_utf16(&range_utf16); 526 - let line_height = bounds_line_height(&self.last_layout); 527 - let line_ix = self.content[..range.start].matches('\n').count(); 528 - let line = self.last_layout.get(line_ix)?; 529 - let line_start = self.line_start_offset(line_ix); 530 - let y = bounds.top() + line_height * line_ix as f32; 531 - Some(Bounds::from_corners( 532 - point( 533 - bounds.left() + line.x_for_index(range.start - line_start), 534 - y, 535 - ), 536 - point( 537 - bounds.left() + line.x_for_index(range.end - line_start), 538 - y + line_height, 539 - ), 540 - )) 541 - } 542 - 543 - fn character_index_for_point( 544 - &mut self, 545 - point: Point<Pixels>, 546 - _window: &mut Window, 547 - _cx: &mut Context<Self>, 548 - ) -> Option<usize> { 549 - Some(self.offset_to_utf16(self.index_for_position(point))) 550 - } 551 - } 552 - 553 - struct BlockTextElement { 554 - editor: Entity<BlockEditor>, 555 - } 556 - 557 - struct PrepaintState { 558 - lines: Vec<ShapedLine>, 559 - line_height: Pixels, 560 - cursor: Option<PaintQuad>, 561 - selections: Vec<PaintQuad>, 562 - } 563 - 564 - impl gpui::IntoElement for BlockTextElement { 565 - type Element = Self; 566 - fn into_element(self) -> Self::Element { 567 - self 568 - } 569 - } 570 - 571 - impl Element for BlockTextElement { 572 - type RequestLayoutState = (); 573 - type PrepaintState = PrepaintState; 574 - 575 - fn id(&self) -> Option<ElementId> { 576 - None 577 - } 578 - 579 - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { 580 - None 581 - } 582 - 583 - fn request_layout( 584 - &mut self, 585 - _id: Option<&GlobalElementId>, 586 - _inspector_id: Option<&gpui::InspectorElementId>, 587 - window: &mut Window, 588 - cx: &mut App, 589 - ) -> (LayoutId, Self::RequestLayoutState) { 590 - let line_count = self.editor.read(cx).content.split('\n').count().max(1); 591 - let mut style = Style::default(); 592 - style.size.width = relative(1.).into(); 593 - style.size.height = (window.line_height() * line_count as f32).into(); 594 - (window.request_layout(style, [], cx), ()) 595 - } 596 - 597 - fn prepaint( 598 - &mut self, 599 - _id: Option<&GlobalElementId>, 600 - _inspector_id: Option<&gpui::InspectorElementId>, 601 - bounds: Bounds<Pixels>, 602 - _request_layout: &mut Self::RequestLayoutState, 603 - window: &mut Window, 604 - cx: &mut App, 605 - ) -> Self::PrepaintState { 606 - let editor = self.editor.read(cx); 607 - let content = editor.content.clone(); 608 - let selected_range = editor.selected_range.clone(); 609 - let cursor = editor.cursor_offset(); 610 - let style = window.text_style(); 611 - let font_size = style.font_size.to_pixels(window.rem_size()); 612 - let line_height = window.line_height(); 613 - 614 - let mut lines = Vec::new(); 615 - let mut byte_offset = 0usize; 616 - for line_text in content.split('\n') { 617 - let run = TextRun { 618 - len: line_text.len(), 619 - font: style.font(), 620 - color: style.color, 621 - background_color: None, 622 - underline: None, 623 - strikethrough: None, 624 - }; 625 - let shaped = window.text_system().shape_line( 626 - SharedString::from(line_text.to_string()), 627 - font_size, 628 - &[run], 629 - None, 630 - ); 631 - lines.push((shaped, byte_offset)); 632 - byte_offset += line_text.len() + 1; 633 - } 634 - 635 - let mut cursor_quad = None; 636 - let mut selection_quads = Vec::new(); 637 - for (line_ix, (line, line_start)) in lines.iter().enumerate() { 638 - let line_end = line_start + line.len(); 639 - let y = bounds.top() + line_height * line_ix as f32; 640 - 641 - if selected_range.is_empty() { 642 - if cursor >= *line_start && cursor <= line_end { 643 - let x = bounds.left() + line.x_for_index(cursor - line_start); 644 - cursor_quad = Some(fill( 645 - Bounds::new(point(x, y), size(px(2.), line_height)), 646 - gpui::blue(), 647 - )); 648 - } 649 - } else if selected_range.start <= line_end && selected_range.end >= *line_start { 650 - let start_x = if selected_range.start <= *line_start { 651 - bounds.left() 652 - } else { 653 - bounds.left() + line.x_for_index(selected_range.start - line_start) 654 - }; 655 - let end_x = if selected_range.end >= line_end { 656 - bounds.left() + line.width 657 - } else { 658 - bounds.left() + line.x_for_index(selected_range.end - line_start) 659 - }; 660 - selection_quads.push(fill( 661 - Bounds::from_corners(point(start_x, y), point(end_x, y + line_height)), 662 - rgba(0x3311ff30), 663 - )); 664 - } 665 - } 666 - 667 - PrepaintState { 668 - lines: lines.into_iter().map(|(l, _)| l).collect(), 669 - line_height, 670 - cursor: cursor_quad, 671 - selections: selection_quads, 672 - } 673 - } 674 - 675 - fn paint( 676 - &mut self, 677 - _id: Option<&GlobalElementId>, 678 - _inspector_id: Option<&gpui::InspectorElementId>, 679 - bounds: Bounds<Pixels>, 680 - _request_layout: &mut Self::RequestLayoutState, 681 - prepaint: &mut Self::PrepaintState, 682 - window: &mut Window, 683 - cx: &mut App, 684 - ) { 685 - let focus_handle = self.editor.read(cx).focus_handle.clone(); 686 - window.handle_input( 687 - &focus_handle, 688 - ElementInputHandler::new(bounds, self.editor.clone()), 689 - cx, 690 - ); 691 - 692 - for selection in prepaint.selections.drain(..) { 693 - window.paint_quad(selection); 694 - } 695 - for (ix, line) in prepaint.lines.iter().enumerate() { 696 - let y = bounds.top() + prepaint.line_height * ix as f32; 697 - line.paint(point(bounds.left(), y), prepaint.line_height, window, cx) 698 - .ok(); 699 - } 700 - if focus_handle.is_focused(window) { 701 - if let Some(cursor) = prepaint.cursor.take() { 702 - window.paint_quad(cursor); 703 - } 704 - } 705 - 706 - let lines = prepaint.lines.clone(); 707 - self.editor.update(cx, |editor, _cx| { 708 - editor.last_layout = lines; 709 - editor.last_bounds = Some(bounds); 710 - }); 711 - } 712 - } 713 - 714 - impl Render for BlockEditor { 715 - fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement { 716 - div() 717 - .flex() 718 - .w_full() 719 - .key_context(CONTEXT) 720 - .track_focus(&self.focus_handle(cx)) 721 - .cursor(CursorStyle::IBeam) 722 - .on_action(cx.listener(Self::move_left)) 723 - .on_action(cx.listener(Self::move_right)) 724 - .on_action(cx.listener(Self::move_up)) 725 - .on_action(cx.listener(Self::move_down)) 726 - .on_action(cx.listener(Self::select_left)) 727 - .on_action(cx.listener(Self::select_right)) 728 - .on_action(cx.listener(Self::select_all)) 729 - .on_action(cx.listener(Self::home)) 730 - .on_action(cx.listener(Self::end)) 731 - .on_action(cx.listener(Self::backspace)) 732 - .on_action(cx.listener(Self::delete)) 733 - .on_action(cx.listener(Self::enter)) 734 - .on_action(cx.listener(Self::shift_enter)) 735 - .on_action(cx.listener(Self::tab)) 736 - .on_action(cx.listener(Self::shift_tab)) 737 - .on_action(cx.listener(Self::move_block_up)) 738 - .on_action(cx.listener(Self::move_block_down)) 739 - .on_action(cx.listener(Self::paste)) 740 - .on_action(cx.listener(Self::copy)) 741 - .on_action(cx.listener(Self::cut)) 742 - .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) 743 - .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) 744 - .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) 745 - .on_mouse_move(cx.listener(Self::on_mouse_move)) 746 - .child(BlockTextElement { 747 - editor: cx.entity(), 748 - }) 749 - } 750 - } 1 + //! A minimal multi-line text editor built directly on GPUI's raw 2 + //! `EntityInputHandler`/`Element` primitives — no `gpui-component`. 3 + //! 4 + //! `gpui-component`'s `Input` came with its own opinionated keybindings for 5 + //! Enter/Tab/Backspace baked in, which fought every attempt to give those 6 + //! keys outline semantics (split/indent/merge) instead of textarea 7 + //! semantics, plus a default-width bug that rendered the one-hot editor as 8 + //! a collapsed sliver. This module owns 100% of the block editor's 9 + //! behavior instead, modeled closely on gpui's own `examples/input.rs` 10 + //! (single-line) extended to multiple lines split on `\n` — no soft 11 + //! word-wrap, matching a plain source-text editing surface. 12 + //! 13 + //! `BlockEditor` only edits text and reports high-level outline intents 14 + //! (split/merge/indent/outdent/blur) via `BlockEditorEvent`; it knows 15 + //! nothing about the graph. The caller (`TrawlerApp`) owns what those 16 + //! intents actually do. 17 + 18 + use std::ops::Range; 19 + 20 + use gpui::{ 21 + actions, div, fill, point, prelude::FluentBuilder as _, px, relative, rgb, rgba, size, App, 22 + Bounds, ClipboardItem, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity, 23 + EntityInputHandler, EventEmitter, FocusHandle, Focusable, GlobalElementId, InteractiveElement, 24 + IntoElement, KeyBinding, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, 25 + PaintQuad, ParentElement, Pixels, Point, Render, ShapedLine, SharedString, Style, Styled, 26 + Subscription, TextRun, UTF16Selection, Window, 27 + }; 28 + use unicode_segmentation::UnicodeSegmentation; 29 + 30 + pub const CONTEXT: &str = "BlockEditor"; 31 + 32 + actions!( 33 + block_editor, 34 + [ 35 + EditorMoveLeft, 36 + EditorMoveRight, 37 + EditorMoveUp, 38 + EditorMoveDown, 39 + EditorSelectLeft, 40 + EditorSelectRight, 41 + EditorSelectAll, 42 + EditorHome, 43 + EditorEnd, 44 + EditorBackspace, 45 + EditorDelete, 46 + EditorEnter, 47 + EditorShiftEnter, 48 + EditorTab, 49 + EditorShiftTab, 50 + EditorMoveBlockUp, 51 + EditorMoveBlockDown, 52 + EditorEscape, 53 + EditorPaste, 54 + EditorCut, 55 + EditorCopy, 56 + ] 57 + ); 58 + 59 + pub fn init(cx: &mut App) { 60 + cx.bind_keys([ 61 + KeyBinding::new("left", EditorMoveLeft, Some(CONTEXT)), 62 + KeyBinding::new("right", EditorMoveRight, Some(CONTEXT)), 63 + KeyBinding::new("up", EditorMoveUp, Some(CONTEXT)), 64 + KeyBinding::new("down", EditorMoveDown, Some(CONTEXT)), 65 + KeyBinding::new("shift-left", EditorSelectLeft, Some(CONTEXT)), 66 + KeyBinding::new("shift-right", EditorSelectRight, Some(CONTEXT)), 67 + KeyBinding::new("ctrl-a", EditorSelectAll, Some(CONTEXT)), 68 + KeyBinding::new("home", EditorHome, Some(CONTEXT)), 69 + KeyBinding::new("end", EditorEnd, Some(CONTEXT)), 70 + KeyBinding::new("backspace", EditorBackspace, Some(CONTEXT)), 71 + KeyBinding::new("delete", EditorDelete, Some(CONTEXT)), 72 + KeyBinding::new("enter", EditorEnter, Some(CONTEXT)), 73 + KeyBinding::new("shift-enter", EditorShiftEnter, Some(CONTEXT)), 74 + KeyBinding::new("tab", EditorTab, Some(CONTEXT)), 75 + KeyBinding::new("shift-tab", EditorShiftTab, Some(CONTEXT)), 76 + KeyBinding::new("alt-up", EditorMoveBlockUp, Some(CONTEXT)), 77 + KeyBinding::new("alt-down", EditorMoveBlockDown, Some(CONTEXT)), 78 + KeyBinding::new("escape", EditorEscape, Some(CONTEXT)), 79 + KeyBinding::new("ctrl-v", EditorPaste, Some(CONTEXT)), 80 + KeyBinding::new("ctrl-c", EditorCopy, Some(CONTEXT)), 81 + KeyBinding::new("ctrl-x", EditorCut, Some(CONTEXT)), 82 + ]); 83 + } 84 + 85 + /// High-level intents `BlockEditor` reports to its owner. The owner (not 86 + /// this module) knows what "split the block" or "merge with previous" 87 + /// means in graph terms. 88 + #[derive(Debug, Clone)] 89 + pub enum BlockEditorEvent { 90 + Blurred, 91 + /// Plain Enter: split into a new sibling block at the cursor (spec: 92 + /// outline-editor "Enter creates a new sibling block"). 93 + SplitRequested, 94 + /// Backspace with an empty selection at content-start: merge into the 95 + /// previous sibling. 96 + MergeWithPreviousRequested, 97 + Indent, 98 + Outdent, 99 + MoveBlockUp, 100 + MoveBlockDown, 101 + /// Up pressed with the cursor already on the block's first line: hand 102 + /// focus to the previous visible block. 103 + FocusPreviousBlock, 104 + /// Down pressed with the cursor already on the block's last line: hand 105 + /// focus to the next visible block. 106 + FocusNextBlock, 107 + /// The user is typing after a `[[` or `#` trigger; the owner should 108 + /// look up matching pages/tags/dates and call 109 + /// `BlockEditor::set_completion_candidates` (spec: outline-editor 110 + /// "Reference completion"). 111 + CompletionQueryChanged { 112 + trigger: char, 113 + query: String, 114 + }, 115 + } 116 + 117 + /// One candidate in the reference-completion popup. 118 + #[derive(Debug, Clone)] 119 + pub struct CompletionCandidate { 120 + /// What the popup row shows. 121 + pub display: String, 122 + /// Text that replaces the typed query. 123 + pub insert_text: String, 124 + /// Appended immediately after `insert_text` (e.g. `"]]"` to close a 125 + /// page reference; empty for tags, which have no closing delimiter). 126 + pub suffix: String, 127 + } 128 + 129 + struct ActiveCompletion { 130 + trigger: char, 131 + /// Byte range of the query text (the part typed after the trigger), 132 + /// which shrinks/grows with `self.content` as the query changes and is 133 + /// treated as the replacement target on accept. 134 + query_range: Range<usize>, 135 + candidates: Vec<CompletionCandidate>, 136 + selected: usize, 137 + } 138 + 139 + pub struct BlockEditor { 140 + focus_handle: FocusHandle, 141 + content: String, 142 + selected_range: Range<usize>, 143 + selection_reversed: bool, 144 + marked_range: Option<Range<usize>>, 145 + last_layout: Vec<ShapedLine>, 146 + last_bounds: Option<Bounds<Pixels>>, 147 + is_selecting: bool, 148 + completion: Option<ActiveCompletion>, 149 + _blur_subscription: Subscription, 150 + } 151 + 152 + impl EventEmitter<BlockEditorEvent> for BlockEditor {} 153 + 154 + impl Focusable for BlockEditor { 155 + fn focus_handle(&self, _: &App) -> FocusHandle { 156 + self.focus_handle.clone() 157 + } 158 + } 159 + 160 + impl BlockEditor { 161 + pub fn new(window: &mut Window, cx: &mut Context<Self>, content: String) -> Self { 162 + let len = content.len(); 163 + let focus_handle = cx.focus_handle(); 164 + let blur_subscription = cx.on_blur(&focus_handle, window, |_this, _window, cx| { 165 + cx.emit(BlockEditorEvent::Blurred); 166 + }); 167 + Self { 168 + focus_handle, 169 + content, 170 + selected_range: len..len, 171 + selection_reversed: false, 172 + marked_range: None, 173 + last_layout: Vec::new(), 174 + last_bounds: None, 175 + is_selecting: false, 176 + completion: None, 177 + _blur_subscription: blur_subscription, 178 + } 179 + } 180 + 181 + pub fn value(&self) -> &str { 182 + &self.content 183 + } 184 + 185 + /// Called by the owner once it has resolved candidates for the most 186 + /// recent `CompletionQueryChanged`. Ignored if the query has since 187 + /// moved on (trigger no longer active, or query text changed again). 188 + pub fn set_completion_candidates( 189 + &mut self, 190 + trigger: char, 191 + query: &str, 192 + candidates: Vec<CompletionCandidate>, 193 + cx: &mut Context<Self>, 194 + ) { 195 + let Some(state) = self.completion.as_mut() else { 196 + return; 197 + }; 198 + if state.trigger != trigger || &self.content[state.query_range.clone()] != query { 199 + return; 200 + } 201 + state.candidates = candidates; 202 + state.selected = 0; 203 + cx.notify(); 204 + } 205 + 206 + /// Find an active `[[` or `#` completion trigger ending at the cursor, 207 + /// on the current line only (spec: "Reference completion"). Returns 208 + /// the trigger char and the byte range of the query text typed since 209 + /// it (excluding the trigger itself). 210 + fn detect_trigger(&self) -> Option<(char, Range<usize>)> { 211 + let cursor = self.cursor_offset(); 212 + let line_start = self.content[..cursor].rfind('\n').map_or(0, |i| i + 1); 213 + let line = &self.content[line_start..cursor]; 214 + 215 + if let Some(pos) = line.rfind("[[") { 216 + let after = &line[pos + 2..]; 217 + if !after.contains("]]") { 218 + return Some(('[', (line_start + pos + 2)..cursor)); 219 + } 220 + } 221 + if let Some(pos) = line.rfind('#') { 222 + let after = &line[pos + 1..]; 223 + let prev_is_word = pos > 0 && line.as_bytes()[pos - 1].is_ascii_alphanumeric(); 224 + if !prev_is_word 225 + && after 226 + .chars() 227 + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 228 + { 229 + return Some(('#', (line_start + pos + 1)..cursor)); 230 + } 231 + } 232 + None 233 + } 234 + 235 + /// Re-evaluate the completion trigger after a content or cursor 236 + /// change; called from every text-mutating `EntityInputHandler` method. 237 + fn sync_completion(&mut self, cx: &mut Context<Self>) { 238 + match self.detect_trigger() { 239 + Some((trigger, range)) => { 240 + let query = self.content[range.clone()].to_string(); 241 + self.completion = Some(ActiveCompletion { 242 + trigger, 243 + query_range: range, 244 + candidates: Vec::new(), 245 + selected: 0, 246 + }); 247 + cx.emit(BlockEditorEvent::CompletionQueryChanged { trigger, query }); 248 + } 249 + None => { 250 + if self.completion.take().is_some() { 251 + cx.notify(); 252 + } 253 + } 254 + } 255 + } 256 + 257 + /// Replace the typed query with the selected candidate's text and 258 + /// close the popup. 259 + fn accept_completion(&mut self, ix: usize, cx: &mut Context<Self>) { 260 + let Some(state) = self.completion.take() else { 261 + return; 262 + }; 263 + let Some(candidate) = state.candidates.get(ix) else { 264 + return; 265 + }; 266 + let insert = format!("{}{}", candidate.insert_text, candidate.suffix); 267 + let range = state.query_range; 268 + self.content = format!( 269 + "{}{}{}", 270 + &self.content[..range.start], 271 + insert, 272 + &self.content[range.end..] 273 + ); 274 + let new_cursor = range.start + insert.len(); 275 + self.selected_range = new_cursor..new_cursor; 276 + cx.notify(); 277 + } 278 + 279 + fn escape(&mut self, _: &EditorEscape, _: &mut Window, cx: &mut Context<Self>) { 280 + if self.completion.take().is_some() { 281 + cx.notify(); 282 + } 283 + } 284 + 285 + pub fn cursor_offset(&self) -> usize { 286 + if self.selection_reversed { 287 + self.selected_range.start 288 + } else { 289 + self.selected_range.end 290 + } 291 + } 292 + 293 + fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) { 294 + self.selected_range = offset..offset; 295 + cx.notify(); 296 + } 297 + 298 + fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) { 299 + if self.selection_reversed { 300 + self.selected_range.start = offset; 301 + } else { 302 + self.selected_range.end = offset; 303 + } 304 + if self.selected_range.end < self.selected_range.start { 305 + self.selection_reversed = !self.selection_reversed; 306 + self.selected_range = self.selected_range.end..self.selected_range.start; 307 + } 308 + cx.notify(); 309 + } 310 + 311 + fn previous_boundary(&self, offset: usize) -> usize { 312 + self.content 313 + .grapheme_indices(true) 314 + .rev() 315 + .find_map(|(idx, _)| (idx < offset).then_some(idx)) 316 + .unwrap_or(0) 317 + } 318 + 319 + fn next_boundary(&self, offset: usize) -> usize { 320 + self.content 321 + .grapheme_indices(true) 322 + .find_map(|(idx, _)| (idx > offset).then_some(idx)) 323 + .unwrap_or(self.content.len()) 324 + } 325 + 326 + /// Byte range of the line containing `offset` (excluding its `\n`). 327 + fn line_bounds(&self, offset: usize) -> Range<usize> { 328 + let start = self.content[..offset].rfind('\n').map_or(0, |i| i + 1); 329 + let end = self.content[offset..] 330 + .find('\n') 331 + .map_or(self.content.len(), |i| offset + i); 332 + start..end 333 + } 334 + 335 + fn move_left(&mut self, _: &EditorMoveLeft, _: &mut Window, cx: &mut Context<Self>) { 336 + if self.selected_range.is_empty() { 337 + self.move_to(self.previous_boundary(self.cursor_offset()), cx); 338 + } else { 339 + self.move_to(self.selected_range.start, cx); 340 + } 341 + } 342 + 343 + fn move_right(&mut self, _: &EditorMoveRight, _: &mut Window, cx: &mut Context<Self>) { 344 + if self.selected_range.is_empty() { 345 + self.move_to(self.next_boundary(self.cursor_offset()), cx); 346 + } else { 347 + self.move_to(self.selected_range.end, cx); 348 + } 349 + } 350 + 351 + /// Column-preserving move to the previous line (byte-offset-within-line 352 + /// approximation, not pixel-accurate for proportional fonts — a 353 + /// reasonable trade for source-text editing; see module doc). At the 354 + /// first line already, hand off to the previous block instead (spec 355 + /// scenario: "Focus transition commits content" — Up/Down move focus 356 + /// across blocks at a content boundary). 357 + fn move_up(&mut self, _: &EditorMoveUp, _: &mut Window, cx: &mut Context<Self>) { 358 + if let Some(state) = self.completion.as_mut() { 359 + if !state.candidates.is_empty() { 360 + state.selected = state 361 + .selected 362 + .checked_sub(1) 363 + .unwrap_or(state.candidates.len() - 1); 364 + cx.notify(); 365 + return; 366 + } 367 + } 368 + let cursor = self.cursor_offset(); 369 + let line = self.line_bounds(cursor); 370 + if line.start == 0 { 371 + cx.emit(BlockEditorEvent::FocusPreviousBlock); 372 + return; 373 + } 374 + let column = cursor - line.start; 375 + let prev_line = self.line_bounds(line.start - 1); 376 + let target = prev_line.start + column.min(prev_line.end - prev_line.start); 377 + self.move_to(self.snap_to_char_boundary(target), cx); 378 + } 379 + 380 + fn move_down(&mut self, _: &EditorMoveDown, _: &mut Window, cx: &mut Context<Self>) { 381 + if let Some(state) = self.completion.as_mut() { 382 + if !state.candidates.is_empty() { 383 + state.selected = (state.selected + 1) % state.candidates.len(); 384 + cx.notify(); 385 + return; 386 + } 387 + } 388 + let cursor = self.cursor_offset(); 389 + let line = self.line_bounds(cursor); 390 + if line.end == self.content.len() { 391 + cx.emit(BlockEditorEvent::FocusNextBlock); 392 + return; 393 + } 394 + let column = cursor - line.start; 395 + let next_line = self.line_bounds(line.end + 1); 396 + let target = next_line.start + column.min(next_line.end - next_line.start); 397 + self.move_to(self.snap_to_char_boundary(target), cx); 398 + } 399 + 400 + fn snap_to_char_boundary(&self, offset: usize) -> usize { 401 + let mut offset = offset.min(self.content.len()); 402 + while offset > 0 && !self.content.is_char_boundary(offset) { 403 + offset -= 1; 404 + } 405 + offset 406 + } 407 + 408 + fn select_left(&mut self, _: &EditorSelectLeft, _: &mut Window, cx: &mut Context<Self>) { 409 + self.select_to(self.previous_boundary(self.cursor_offset()), cx); 410 + } 411 + 412 + fn select_right(&mut self, _: &EditorSelectRight, _: &mut Window, cx: &mut Context<Self>) { 413 + self.select_to(self.next_boundary(self.cursor_offset()), cx); 414 + } 415 + 416 + fn select_all(&mut self, _: &EditorSelectAll, _: &mut Window, cx: &mut Context<Self>) { 417 + self.selected_range = 0..self.content.len(); 418 + cx.notify(); 419 + } 420 + 421 + fn home(&mut self, _: &EditorHome, _: &mut Window, cx: &mut Context<Self>) { 422 + let line = self.line_bounds(self.cursor_offset()); 423 + self.move_to(line.start, cx); 424 + } 425 + 426 + fn end(&mut self, _: &EditorEnd, _: &mut Window, cx: &mut Context<Self>) { 427 + let line = self.line_bounds(self.cursor_offset()); 428 + self.move_to(line.end, cx); 429 + } 430 + 431 + fn backspace(&mut self, _: &EditorBackspace, window: &mut Window, cx: &mut Context<Self>) { 432 + if self.selected_range.is_empty() && self.cursor_offset() == 0 { 433 + cx.emit(BlockEditorEvent::MergeWithPreviousRequested); 434 + return; 435 + } 436 + if self.selected_range.is_empty() { 437 + self.select_to(self.previous_boundary(self.cursor_offset()), cx); 438 + } 439 + self.replace_text_in_range(None, "", window, cx); 440 + } 441 + 442 + fn delete(&mut self, _: &EditorDelete, window: &mut Window, cx: &mut Context<Self>) { 443 + if self.selected_range.is_empty() { 444 + self.select_to(self.next_boundary(self.cursor_offset()), cx); 445 + } 446 + self.replace_text_in_range(None, "", window, cx); 447 + } 448 + 449 + fn enter(&mut self, _: &EditorEnter, _: &mut Window, cx: &mut Context<Self>) { 450 + if let Some(state) = self.completion.as_ref() { 451 + if !state.candidates.is_empty() { 452 + self.accept_completion(state.selected, cx); 453 + return; 454 + } 455 + } 456 + cx.emit(BlockEditorEvent::SplitRequested); 457 + } 458 + 459 + fn shift_enter(&mut self, _: &EditorShiftEnter, window: &mut Window, cx: &mut Context<Self>) { 460 + self.replace_text_in_range(None, "\n", window, cx); 461 + } 462 + 463 + fn tab(&mut self, _: &EditorTab, _: &mut Window, cx: &mut Context<Self>) { 464 + cx.emit(BlockEditorEvent::Indent); 465 + } 466 + 467 + fn shift_tab(&mut self, _: &EditorShiftTab, _: &mut Window, cx: &mut Context<Self>) { 468 + cx.emit(BlockEditorEvent::Outdent); 469 + } 470 + 471 + fn move_block_up(&mut self, _: &EditorMoveBlockUp, _: &mut Window, cx: &mut Context<Self>) { 472 + cx.emit(BlockEditorEvent::MoveBlockUp); 473 + } 474 + 475 + fn move_block_down(&mut self, _: &EditorMoveBlockDown, _: &mut Window, cx: &mut Context<Self>) { 476 + cx.emit(BlockEditorEvent::MoveBlockDown); 477 + } 478 + 479 + fn paste(&mut self, _: &EditorPaste, window: &mut Window, cx: &mut Context<Self>) { 480 + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { 481 + self.replace_text_in_range(None, &text, window, cx); 482 + } 483 + } 484 + 485 + fn copy(&mut self, _: &EditorCopy, _: &mut Window, cx: &mut Context<Self>) { 486 + if !self.selected_range.is_empty() { 487 + cx.write_to_clipboard(ClipboardItem::new_string( 488 + self.content[self.selected_range.clone()].to_string(), 489 + )); 490 + } 491 + } 492 + 493 + fn cut(&mut self, _: &EditorCut, window: &mut Window, cx: &mut Context<Self>) { 494 + if !self.selected_range.is_empty() { 495 + cx.write_to_clipboard(ClipboardItem::new_string( 496 + self.content[self.selected_range.clone()].to_string(), 497 + )); 498 + self.replace_text_in_range(None, "", window, cx); 499 + } 500 + } 501 + 502 + fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context<Self>) { 503 + self.is_selecting = true; 504 + let offset = self.index_for_position(event.position); 505 + if event.modifiers.shift { 506 + self.select_to(offset, cx); 507 + } else { 508 + self.move_to(offset, cx); 509 + } 510 + } 511 + 512 + fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) { 513 + self.is_selecting = false; 514 + } 515 + 516 + fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) { 517 + if self.is_selecting { 518 + let offset = self.index_for_position(event.position); 519 + self.select_to(offset, cx); 520 + } 521 + } 522 + 523 + fn index_for_position(&self, position: Point<Pixels>) -> usize { 524 + let Some(bounds) = self.last_bounds else { 525 + return 0; 526 + }; 527 + if self.last_layout.is_empty() { 528 + return 0; 529 + } 530 + let line_height = bounds_line_height(&self.last_layout); 531 + let relative_y = (position.y - bounds.top()).max(px(0.)); 532 + let line_ix = ((relative_y / line_height) as usize).min(self.last_layout.len() - 1); 533 + let line = &self.last_layout[line_ix]; 534 + let line_start = self.line_start_offset(line_ix); 535 + line_start + line.closest_index_for_x(position.x - bounds.left()) 536 + } 537 + 538 + fn line_start_offset(&self, line_ix: usize) -> usize { 539 + self.content 540 + .split('\n') 541 + .take(line_ix) 542 + .map(|l| l.len() + 1) 543 + .sum() 544 + } 545 + 546 + /// Window-space position of the bottom-left corner of the cursor's 547 + /// current line, from the last paint — where a popup anchored to the 548 + /// cursor (not the block's layout origin) should appear. 549 + fn cursor_screen_position(&self) -> Option<Point<Pixels>> { 550 + let bounds = self.last_bounds?; 551 + if self.last_layout.is_empty() { 552 + return None; 553 + } 554 + let line_height = bounds_line_height(&self.last_layout); 555 + let cursor = self.cursor_offset(); 556 + let line_ix = self.content[..cursor].matches('\n').count(); 557 + let line = self.last_layout.get(line_ix)?; 558 + let line_start = self.line_start_offset(line_ix); 559 + let x = bounds.left() + line.x_for_index(cursor - line_start); 560 + let y = bounds.top() + line_height * (line_ix + 1) as f32; 561 + Some(point(x, y)) 562 + } 563 + 564 + fn offset_from_utf16(&self, offset: usize) -> usize { 565 + let mut utf8_offset = 0; 566 + let mut utf16_count = 0; 567 + for ch in self.content.chars() { 568 + if utf16_count >= offset { 569 + break; 570 + } 571 + utf16_count += ch.len_utf16(); 572 + utf8_offset += ch.len_utf8(); 573 + } 574 + utf8_offset 575 + } 576 + 577 + fn offset_to_utf16(&self, offset: usize) -> usize { 578 + let mut utf16_offset = 0; 579 + let mut utf8_count = 0; 580 + for ch in self.content.chars() { 581 + if utf8_count >= offset { 582 + break; 583 + } 584 + utf8_count += ch.len_utf8(); 585 + utf16_offset += ch.len_utf16(); 586 + } 587 + utf16_offset 588 + } 589 + 590 + fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> { 591 + self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) 592 + } 593 + 594 + fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> { 595 + self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) 596 + } 597 + } 598 + 599 + fn bounds_line_height(lines: &[ShapedLine]) -> Pixels { 600 + lines 601 + .first() 602 + .map(|l| (l.ascent + l.descent).max(px(1.))) 603 + .unwrap_or(px(20.)) 604 + } 605 + 606 + impl EntityInputHandler for BlockEditor { 607 + fn text_for_range( 608 + &mut self, 609 + range_utf16: Range<usize>, 610 + actual_range: &mut Option<Range<usize>>, 611 + _window: &mut Window, 612 + _cx: &mut Context<Self>, 613 + ) -> Option<String> { 614 + let range = self.range_from_utf16(&range_utf16); 615 + actual_range.replace(self.range_to_utf16(&range)); 616 + Some(self.content[range].to_string()) 617 + } 618 + 619 + fn selected_text_range( 620 + &mut self, 621 + _ignore_disabled_input: bool, 622 + _window: &mut Window, 623 + _cx: &mut Context<Self>, 624 + ) -> Option<UTF16Selection> { 625 + Some(UTF16Selection { 626 + range: self.range_to_utf16(&self.selected_range), 627 + reversed: self.selection_reversed, 628 + }) 629 + } 630 + 631 + fn marked_text_range( 632 + &self, 633 + _window: &mut Window, 634 + _cx: &mut Context<Self>, 635 + ) -> Option<Range<usize>> { 636 + self.marked_range 637 + .as_ref() 638 + .map(|range| self.range_to_utf16(range)) 639 + } 640 + 641 + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) { 642 + self.marked_range = None; 643 + } 644 + 645 + fn replace_text_in_range( 646 + &mut self, 647 + range_utf16: Option<Range<usize>>, 648 + new_text: &str, 649 + _: &mut Window, 650 + cx: &mut Context<Self>, 651 + ) { 652 + let range = range_utf16 653 + .as_ref() 654 + .map(|r| self.range_from_utf16(r)) 655 + .or(self.marked_range.clone()) 656 + .unwrap_or(self.selected_range.clone()); 657 + 658 + self.content = 659 + self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]; 660 + self.selected_range = range.start + new_text.len()..range.start + new_text.len(); 661 + self.marked_range.take(); 662 + self.sync_completion(cx); 663 + cx.notify(); 664 + } 665 + 666 + fn replace_and_mark_text_in_range( 667 + &mut self, 668 + range_utf16: Option<Range<usize>>, 669 + new_text: &str, 670 + new_selected_range_utf16: Option<Range<usize>>, 671 + _window: &mut Window, 672 + cx: &mut Context<Self>, 673 + ) { 674 + let range = range_utf16 675 + .as_ref() 676 + .map(|r| self.range_from_utf16(r)) 677 + .or(self.marked_range.clone()) 678 + .unwrap_or(self.selected_range.clone()); 679 + 680 + self.content = 681 + self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]; 682 + self.marked_range = if new_text.is_empty() { 683 + None 684 + } else { 685 + Some(range.start..range.start + new_text.len()) 686 + }; 687 + self.selected_range = new_selected_range_utf16 688 + .as_ref() 689 + .map(|r| self.range_from_utf16(r)) 690 + .map(|r| r.start + range.start..r.end + range.start) 691 + .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); 692 + self.sync_completion(cx); 693 + cx.notify(); 694 + } 695 + 696 + fn bounds_for_range( 697 + &mut self, 698 + range_utf16: Range<usize>, 699 + bounds: Bounds<Pixels>, 700 + _window: &mut Window, 701 + _cx: &mut Context<Self>, 702 + ) -> Option<Bounds<Pixels>> { 703 + let range = self.range_from_utf16(&range_utf16); 704 + let line_height = bounds_line_height(&self.last_layout); 705 + let line_ix = self.content[..range.start].matches('\n').count(); 706 + let line = self.last_layout.get(line_ix)?; 707 + let line_start = self.line_start_offset(line_ix); 708 + let y = bounds.top() + line_height * line_ix as f32; 709 + Some(Bounds::from_corners( 710 + point( 711 + bounds.left() + line.x_for_index(range.start - line_start), 712 + y, 713 + ), 714 + point( 715 + bounds.left() + line.x_for_index(range.end - line_start), 716 + y + line_height, 717 + ), 718 + )) 719 + } 720 + 721 + fn character_index_for_point( 722 + &mut self, 723 + point: Point<Pixels>, 724 + _window: &mut Window, 725 + _cx: &mut Context<Self>, 726 + ) -> Option<usize> { 727 + Some(self.offset_to_utf16(self.index_for_position(point))) 728 + } 729 + } 730 + 731 + struct BlockTextElement { 732 + editor: Entity<BlockEditor>, 733 + } 734 + 735 + struct PrepaintState { 736 + lines: Vec<ShapedLine>, 737 + line_height: Pixels, 738 + cursor: Option<PaintQuad>, 739 + selections: Vec<PaintQuad>, 740 + } 741 + 742 + impl gpui::IntoElement for BlockTextElement { 743 + type Element = Self; 744 + fn into_element(self) -> Self::Element { 745 + self 746 + } 747 + } 748 + 749 + impl Element for BlockTextElement { 750 + type RequestLayoutState = (); 751 + type PrepaintState = PrepaintState; 752 + 753 + fn id(&self) -> Option<ElementId> { 754 + None 755 + } 756 + 757 + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { 758 + None 759 + } 760 + 761 + fn request_layout( 762 + &mut self, 763 + _id: Option<&GlobalElementId>, 764 + _inspector_id: Option<&gpui::InspectorElementId>, 765 + window: &mut Window, 766 + cx: &mut App, 767 + ) -> (LayoutId, Self::RequestLayoutState) { 768 + let line_count = self.editor.read(cx).content.split('\n').count().max(1); 769 + let mut style = Style::default(); 770 + style.size.width = relative(1.).into(); 771 + style.size.height = (window.line_height() * line_count as f32).into(); 772 + (window.request_layout(style, [], cx), ()) 773 + } 774 + 775 + fn prepaint( 776 + &mut self, 777 + _id: Option<&GlobalElementId>, 778 + _inspector_id: Option<&gpui::InspectorElementId>, 779 + bounds: Bounds<Pixels>, 780 + _request_layout: &mut Self::RequestLayoutState, 781 + window: &mut Window, 782 + cx: &mut App, 783 + ) -> Self::PrepaintState { 784 + let editor = self.editor.read(cx); 785 + let content = editor.content.clone(); 786 + let selected_range = editor.selected_range.clone(); 787 + let cursor = editor.cursor_offset(); 788 + let style = window.text_style(); 789 + let font_size = style.font_size.to_pixels(window.rem_size()); 790 + let line_height = window.line_height(); 791 + 792 + let mut lines = Vec::new(); 793 + let mut byte_offset = 0usize; 794 + for line_text in content.split('\n') { 795 + let run = TextRun { 796 + len: line_text.len(), 797 + font: style.font(), 798 + color: style.color, 799 + background_color: None, 800 + underline: None, 801 + strikethrough: None, 802 + }; 803 + let shaped = window.text_system().shape_line( 804 + SharedString::from(line_text.to_string()), 805 + font_size, 806 + &[run], 807 + None, 808 + ); 809 + lines.push((shaped, byte_offset)); 810 + byte_offset += line_text.len() + 1; 811 + } 812 + 813 + let mut cursor_quad = None; 814 + let mut selection_quads = Vec::new(); 815 + for (line_ix, (line, line_start)) in lines.iter().enumerate() { 816 + let line_end = line_start + line.len(); 817 + let y = bounds.top() + line_height * line_ix as f32; 818 + 819 + if selected_range.is_empty() { 820 + if cursor >= *line_start && cursor <= line_end { 821 + let x = bounds.left() + line.x_for_index(cursor - line_start); 822 + cursor_quad = Some(fill( 823 + Bounds::new(point(x, y), size(px(2.), line_height)), 824 + gpui::blue(), 825 + )); 826 + } 827 + } else if selected_range.start <= line_end && selected_range.end >= *line_start { 828 + let start_x = if selected_range.start <= *line_start { 829 + bounds.left() 830 + } else { 831 + bounds.left() + line.x_for_index(selected_range.start - line_start) 832 + }; 833 + let end_x = if selected_range.end >= line_end { 834 + bounds.left() + line.width 835 + } else { 836 + bounds.left() + line.x_for_index(selected_range.end - line_start) 837 + }; 838 + selection_quads.push(fill( 839 + Bounds::from_corners(point(start_x, y), point(end_x, y + line_height)), 840 + rgba(0x3311ff30), 841 + )); 842 + } 843 + } 844 + 845 + PrepaintState { 846 + lines: lines.into_iter().map(|(l, _)| l).collect(), 847 + line_height, 848 + cursor: cursor_quad, 849 + selections: selection_quads, 850 + } 851 + } 852 + 853 + fn paint( 854 + &mut self, 855 + _id: Option<&GlobalElementId>, 856 + _inspector_id: Option<&gpui::InspectorElementId>, 857 + bounds: Bounds<Pixels>, 858 + _request_layout: &mut Self::RequestLayoutState, 859 + prepaint: &mut Self::PrepaintState, 860 + window: &mut Window, 861 + cx: &mut App, 862 + ) { 863 + let focus_handle = self.editor.read(cx).focus_handle.clone(); 864 + window.handle_input( 865 + &focus_handle, 866 + ElementInputHandler::new(bounds, self.editor.clone()), 867 + cx, 868 + ); 869 + 870 + for selection in prepaint.selections.drain(..) { 871 + window.paint_quad(selection); 872 + } 873 + for (ix, line) in prepaint.lines.iter().enumerate() { 874 + let y = bounds.top() + prepaint.line_height * ix as f32; 875 + line.paint(point(bounds.left(), y), prepaint.line_height, window, cx) 876 + .ok(); 877 + } 878 + if focus_handle.is_focused(window) { 879 + if let Some(cursor) = prepaint.cursor.take() { 880 + window.paint_quad(cursor); 881 + } 882 + } 883 + 884 + let lines = prepaint.lines.clone(); 885 + self.editor.update(cx, |editor, _cx| { 886 + editor.last_layout = lines; 887 + editor.last_bounds = Some(bounds); 888 + }); 889 + } 890 + } 891 + 892 + impl BlockEditor { 893 + fn render_completion_popup(&self, cx: &Context<Self>) -> Option<gpui::AnyElement> { 894 + let state = self.completion.as_ref()?; 895 + if state.candidates.is_empty() { 896 + return None; 897 + } 898 + let items = state.candidates.iter().enumerate().map(|(ix, candidate)| { 899 + let selected = ix == state.selected; 900 + div() 901 + .id(("completion-candidate", ix)) 902 + .px_2() 903 + .py_1() 904 + .when(selected, |d| d.bg(rgb(0x2a3a55))) 905 + .cursor_pointer() 906 + .on_mouse_down( 907 + MouseButton::Left, 908 + cx.listener(move |this, _event, _window, cx| { 909 + this.accept_completion(ix, cx); 910 + }), 911 + ) 912 + .child(candidate.display.clone()) 913 + }); 914 + // Anchor to the cursor's own screen position (not this element's 915 + // layout origin, which for a multi-line block sits at the top of 916 + // the whole text area and could be well above where the user is 917 + // actually typing) — falling back to that origin only if a 918 + // position hasn't been painted yet. 919 + let mut anchor = gpui::anchored(); 920 + if let Some(position) = self.cursor_screen_position() { 921 + anchor = anchor.position(position); 922 + } 923 + // Plain flex children stack in normal document flow — they'd push 924 + // subsequent rows in the virtualized list down instead of 925 + // floating over them. `anchored()` pulls this out of layout 926 + // (`Position::Absolute`, so it takes no space), and `deferred()` 927 + // delays its paint until after every sibling/ancestor has already 928 + // painted, so it renders on top instead of getting drawn over by 929 + // whatever list row comes after it in document order. 930 + Some( 931 + gpui::deferred( 932 + anchor.child( 933 + div() 934 + .flex() 935 + .flex_col() 936 + .bg(rgb(0x252525)) 937 + .border_1() 938 + .border_color(rgb(0x3a3a3a)) 939 + .rounded_md() 940 + .children(items), 941 + ), 942 + ) 943 + .with_priority(1) 944 + .into_any_element(), 945 + ) 946 + } 947 + } 948 + 949 + impl Render for BlockEditor { 950 + fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement { 951 + div() 952 + .flex() 953 + .flex_col() 954 + .w_full() 955 + .key_context(CONTEXT) 956 + .track_focus(&self.focus_handle(cx)) 957 + .cursor(CursorStyle::IBeam) 958 + .on_action(cx.listener(Self::move_left)) 959 + .on_action(cx.listener(Self::move_right)) 960 + .on_action(cx.listener(Self::move_up)) 961 + .on_action(cx.listener(Self::move_down)) 962 + .on_action(cx.listener(Self::select_left)) 963 + .on_action(cx.listener(Self::select_right)) 964 + .on_action(cx.listener(Self::select_all)) 965 + .on_action(cx.listener(Self::home)) 966 + .on_action(cx.listener(Self::end)) 967 + .on_action(cx.listener(Self::backspace)) 968 + .on_action(cx.listener(Self::delete)) 969 + .on_action(cx.listener(Self::enter)) 970 + .on_action(cx.listener(Self::shift_enter)) 971 + .on_action(cx.listener(Self::tab)) 972 + .on_action(cx.listener(Self::shift_tab)) 973 + .on_action(cx.listener(Self::move_block_up)) 974 + .on_action(cx.listener(Self::move_block_down)) 975 + .on_action(cx.listener(Self::escape)) 976 + .on_action(cx.listener(Self::paste)) 977 + .on_action(cx.listener(Self::copy)) 978 + .on_action(cx.listener(Self::cut)) 979 + .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) 980 + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) 981 + .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) 982 + .on_mouse_move(cx.listener(Self::on_mouse_move)) 983 + .child(BlockTextElement { 984 + editor: cx.entity(), 985 + }) 986 + .children(self.render_completion_popup(cx)) 987 + } 988 + }
+82 -1
crates/trawler/src/main.rs
··· 20 20 use std::ops::Range; 21 21 use std::path::{Path, PathBuf}; 22 22 23 - use editor::{BlockEditor, BlockEditorEvent}; 23 + use editor::{BlockEditor, BlockEditorEvent, CompletionCandidate}; 24 24 use gpui::{ 25 25 div, px, rgb, size, uniform_list, App, AppContext as _, Application, Bounds, ClickEvent, 26 26 Context, Entity, Focusable as _, FontWeight, InteractiveElement as _, IntoElement, ··· 147 147 BlockEditorEvent::MoveBlockDown => this.move_focused(1, cx), 148 148 BlockEditorEvent::FocusPreviousBlock => this.focus_adjacent_row(-1, cx), 149 149 BlockEditorEvent::FocusNextBlock => this.focus_adjacent_row(1, cx), 150 + BlockEditorEvent::CompletionQueryChanged { trigger, query } => { 151 + this.update_completions(*trigger, query.clone(), cx); 152 + } 150 153 }); 151 154 152 155 let focus_handle = input.read(cx).focus_handle(cx); ··· 352 355 }); 353 356 }); 354 357 } 358 + 359 + /// Look up completion candidates for a `[[`/`#` trigger (spec: 360 + /// outline-editor "Reference completion") and hand them back to the 361 + /// live editor. Rebuilds the index fresh each call — fine at personal- 362 + /// notebook scale (see Spike S3), but a candidate for caching if graphs 363 + /// grow large enough that every keystroke rebuilding becomes visible. 364 + fn update_completions(&mut self, trigger: char, query: String, cx: &mut Context<Self>) { 365 + let Some(editor) = self.editor.as_ref() else { 366 + return; 367 + }; 368 + let input = editor.input.clone(); 369 + let index = GraphIndex::rebuild(self.storage.doc()); 370 + let query_lower = query.to_lowercase(); 371 + 372 + let candidates = match trigger { 373 + '[' => { 374 + let mut candidates: Vec<CompletionCandidate> = index 375 + .pages_by_name 376 + .keys() 377 + .filter(|name| fuzzy_contains(&name.to_lowercase(), &query_lower)) 378 + .map(|name| CompletionCandidate { 379 + display: name.clone(), 380 + insert_text: name.clone(), 381 + suffix: "]]".to_string(), 382 + }) 383 + .collect(); 384 + candidates.extend(date_candidates(&query_lower)); 385 + candidates.sort_by(|a, b| a.display.cmp(&b.display)); 386 + candidates.truncate(8); 387 + candidates 388 + } 389 + '#' => { 390 + let mut candidates: Vec<CompletionCandidate> = index 391 + .tags 392 + .keys() 393 + .filter(|name| fuzzy_contains(&name.to_lowercase(), &query_lower)) 394 + .map(|name| CompletionCandidate { 395 + display: format!("#{name}"), 396 + insert_text: name.clone(), 397 + suffix: String::new(), 398 + }) 399 + .collect(); 400 + candidates.sort_by(|a, b| a.display.cmp(&b.display)); 401 + candidates.truncate(8); 402 + candidates 403 + } 404 + _ => Vec::new(), 405 + }; 406 + 407 + input.update(cx, |editor, cx| { 408 + editor.set_completion_candidates(trigger, &query, candidates, cx); 409 + }); 410 + } 411 + } 412 + 413 + /// Case-insensitive substring match — a lightweight stand-in for a real 414 + /// fuzzy-match algorithm. `query` is already lowercased by the caller. 415 + fn fuzzy_contains(haystack_lower: &str, query_lower: &str) -> bool { 416 + query_lower.is_empty() || haystack_lower.contains(query_lower) 417 + } 418 + 419 + /// Journal date shortcuts offered alongside page candidates when typing a 420 + /// `[[` reference (spec: "date completion for journal refs"). 421 + fn date_candidates(query_lower: &str) -> Vec<CompletionCandidate> { 422 + let today = chrono::Local::now().date_naive(); 423 + [ 424 + ("Today", today), 425 + ("Tomorrow", today + chrono::Duration::days(1)), 426 + ("Yesterday", today - chrono::Duration::days(1)), 427 + ] 428 + .into_iter() 429 + .filter(|(label, _)| fuzzy_contains(&label.to_lowercase(), query_lower)) 430 + .map(|(label, date)| CompletionCandidate { 431 + display: format!("{label} ({date})"), 432 + insert_text: date.format("%Y-%m-%d").to_string(), 433 + suffix: "]]".to_string(), 434 + }) 435 + .collect() 355 436 } 356 437 357 438 /// First-run content so the outline view has something real to render and
+1 -1
openspec/changes/trawler-mvp/tasks.md
··· 42 42 - [x] 5.3 Markdown renderer for inactive blocks (pulldown-cmark or tree-sitter-markdown): paragraphs, emphasis, code spans/fences, quotes, embedded lists, links, plus node-reference inline extension 43 43 - [x] 5.4 One-hot editor: multi-line focused-block editor, focus model, editor attach/detach, commit-on-blur; measure transition latency from the first working build (spec: outline-editor/Editing latency) 44 44 - [x] 5.5 Outline keybindings: Enter/new block, Shift+Enter/newline within block, split at cursor, Tab/Shift+Tab, move up/down, backspace-merge, fold/unfold 45 - - [ ] 5.6 Reference completion: `[[` and `#` popup with fuzzy matching; date completion for journal refs 45 + - [x] 5.6 Reference completion: `[[` and `#` popup with fuzzy matching; date completion for journal refs 46 46 47 47 ## 6. Journal & Navigation (trawler) 48 48