Vectrum
Sign In

Rich text composer: Markdown styled as you type; README for the new features

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 62a5dd2commit9f23b3a

Showing 4 changed files with 1103 additions and 16 deletions.

+40−11README.mdView File
@@ -55,15 +55,43 @@ follow the desktop.
5555 uncommitted changes, and the thread's cost.
5656 - Interrupt with Esc or the stop button. Messages sent while the agent works are queued.
5757 - Crash recovery: if a process dies, the next message restarts it and resumes the session.
58+ - Threads are saved as you go and come back after a restart, settled ones included; the next
59+ message resumes the conversation. Optionally, threads a crash or restart interrupted
60+ continue on their own.
61+ - New threads can start in a git worktree of their own: a fresh `vectrum/<title>` branch
62+ checked out under `~/.local/share/vectrum-code/worktrees`, optionally from the branch as
63+ origin has it, with submodules filled in. Pick Local or New worktree per thread on the
64+ start screen.
65+ - Thread titles written by a small model from the first message (Settings → Text generation).
66+ - Pin threads to the top, snooze them until later, rename them (right-click a thread card).
67+ The sidebar can group threads by project, fold working threads into a Working section, and
68+ order projects by last message, name or date added.
69+ - When a usage limit stops a thread, it can snooze until the limit resets and continue then.
70+ Idle threads, and worktree threads whose pull request merged, can settle on their own.
71+ - Send while the agent works to steer it, or queue the message for after the turn (and
72+ Ctrl+Enter for the other one). Queued messages wait above the composer.
73+ - A `/` menu with Claude's commands and skills (`$` for skills alone).
74+ - Diffs with hidden whitespace changes, foldable files, line numbers, and a stacked or
75+ side-by-side layout.
76+ - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on
77+ one device, and paste its pairing code (address and key) under Settings → Connections on
78+ another. The host's projects and threads appear in the sidebar tagged with its name; open,
79+ continue or start threads there and they run on the host. The connection is a WebSocket
80+ guarded by the key but not encrypted, so use it on trusted networks or over a VPN.
5881 - A settings view (Ctrl+, or **Settings** in the sidebar), laid out like T3 Code's:
59- - **General**: the model, reasoning and access new threads start with (or follow the
60- open thread), folding finished turns, sending with Ctrl+Enter, showing costs, the
61- editor "Open in" uses, and desktop notifications when a background thread finishes or
62- waits for you.
63- - **Appearance**: Bone, Graphite or Auto colorway, and comfortable or compact thread cards.
64- - **Providers**: Claude's account and models, Pi's status with a refresh, and a path
65- override for each binary.
66- - **Keybindings**, an **Archive** of settled threads to restore or delete, and **About**.
82+ - **General**: what new threads start with (model, reasoning, permissions, workspace,
83+ submodules), the text generation model, projects and threads, organization of the
84+ sidebar, and confirmations (unpin, settle, delete, and the quit shortcut).
85+ - **Behavior**: notifications (desktop, with sound, in-app), time format, response
86+ streaming, turn folding and costs, proactive diffs, diff defaults, the composer (skills in
87+ the `/` menu, collapsing on scroll, send key, follow-up behavior), update checks,
88+ continuing after restarts, background activity, and the editor "Open in" uses.
89+ - **Appearance**: the colorway and thread card density.
90+ - **Providers**: every agent instance (several Claude or Pi instances side by side, say two
91+ accounts), each with a switch, its version, login and plan, display name, binary, config
92+ folder, launch arguments and environment variables, plus updates and the health check
93+ interval.
94+ - **Connections**, **Keybindings**, an **Archive** of settled threads, and **About**.
6795
6896 ## Requirements
6997
@@ -102,8 +130,9 @@ cargo run --release -- /path/to/project # defaults to the current directory
102130
103131 Favorite models and everything in the settings view are saved in
104132 `~/.config/vectrum-code/settings.json`. The environment variables above win over the binary
105- paths set there.
106- Threads started without a project run in `~/.local/share/vectrum-code/scratch`.
133+ paths set there for each agent's first instance. Threads and the project list live in
134+ `~/.local/share/vectrum-code` (`threads/`, `state.json`); threads started without a project run
135+ in its `scratch` folder.
107136
108137 ## Layout
109138
@@ -131,7 +160,7 @@ Threads started without a project run in `~/.local/share/vectrum-code/scratch`.
131160
132161 ## Not yet supported
133162
134- Threads, settled or not, aren't persisted across app restarts. There is no UI for Claude's
163+ There is no UI for Claude's
135164 `AskUserQuestion` (the tool is disabled, so Claude asks in plain text) or for Pi extensions
136165 that ask for free text, code blocks have no syntax highlighting, and T3 Code's project
137166 scripts and terminal drawer aren't here yet.
+6−5src/ui/chat.rsView File
@@ -9,7 +9,7 @@ use std::{
99
1010 use gpui_kit::component::{
1111 Icon, IconName, Sizable as _, h_flex,
12- input::{self, InputEvent, Textarea, TextareaState},
12+ input::{self, EditorState, InputEvent},
1313 menu::{DropdownMenu as _, PopupMenuItem},
1414 message_scroller::{MessageScroller, MessageScrollerState},
1515 popover::Popover,
@@ -32,6 +32,7 @@ use crate::session::{
3232 use crate::settings::{self, FollowUp, WorkspaceMode};
3333 use crate::ui::changes::{self, DiffAction, DiffView};
3434 use crate::ui::icons::Lucide;
35+ use crate::ui::markdown_input::{self, MarkdownInput};
3536 use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
3637 use crate::ui::pickers::{self, ModelPicker, ModelPickerEvent};
3738 use crate::ui::workspace::ProjectDirectory;
@@ -70,7 +71,7 @@ enum Picker {
7071
7172 pub struct ChatView {
7273 session: Entity<Session>,
73- composer: Entity<TextareaState>,
74+ composer: Entity<EditorState>,
7475 scroller: Entity<MessageScrollerState>,
7576 model_picker: Entity<ModelPicker>,
7677 open_picker: Option<Picker>,
@@ -91,8 +92,7 @@ impl EventEmitter<ChatEvent> for ChatView {}
9192 impl ChatView {
9293 pub fn new(session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) -> Self {
9394 let composer = cx.new(|cx| {
94- TextareaState::new(window, cx)
95- .auto_grow(2, 12)
95+ markdown_input::composer_state(EditorState::new(window, cx), settings::get(|settings| settings.rich_composer))
9696 .submit_on_enter(!settings::get(|settings| settings.send_with_ctrl_enter))
9797 .placeholder("Ask for changes, send follow-ups, or attach images")
9898 });
@@ -193,6 +193,7 @@ impl ChatView {
193193 pub fn settings_changed(&mut self, _: &mut Window, cx: &mut Context<Self>) {
194194 let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter);
195195 self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(!ctrl_enter, cx));
196+ markdown_input::enable(&self.composer, settings::get(|settings| settings.rich_composer), cx);
196197 self.scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
197198 cx.notify();
198199 }
@@ -1028,7 +1029,7 @@ impl ChatView {
10281029 .px(px(12.))
10291030 .text_size(px(14.))
10301031 .text_color(p.ink)
1031- .child(Textarea::new(&self.composer).appearance(false));
1032+ .child(MarkdownInput::new(&self.composer).rows(2, 12));
10321033 let card = if collapsed {
10331034 // Resting: one line, a click (or typing) away from the full composer.
10341035 h_flex()
+1056−0src/ui/markdown_input.rsaddedView File
@@ -0,0 +1,1056 @@
1+ //! The rich text composer: Markdown styled live as you type.
2+ //!
3+ //! The text stays plain Markdown (that is what gets sent); only its styling
4+ //! changes. Headings and `**strong**` turn bold, `*emphasis*` italic, code
5+ //! sits on a faint well, quotes and fenced blocks are muted, list markers,
6+ //! links and a leading `/command` or `$skill` are tinted, and the syntax
7+ //! characters themselves fade back.
8+ //!
9+ //! gpui-base only paints highlighter runs in its code-editor layout, so the
10+ //! rich composer is an [`EditorState`] dressed as the plain composer: no
11+ //! gutter, folding, indent guides, auto-closing or smart indent, soft wrap on,
12+ //! and [`MarkdownInput`] grows it from two to twelve rows the way
13+ //! `TextareaState::auto_grow` does. [`MarkdownHighlighter`] supplies the runs.
14+ //!
15+ //! ```ignore
16+ //! let composer = cx.new(|cx| {
17+ //! markdown_input::composer_state(EditorState::new(window, cx), rich)
18+ //! .submit_on_enter(true)
19+ //! .placeholder("Ask for changes")
20+ //! });
21+ //! // render: MarkdownInput::new(&composer)
22+ //! // toggle: markdown_input::enable(&composer, on, cx)
23+ //! ```
24+
25+ use std::{cell::Cell, ops::Range, rc::Rc};
26+
27+ use gpui_kit::component::input::{
28+ self, Editor, EditorState, FoldRange, HighlightStyleResolver, InputEdit, InputHighlighter,
29+ InputHighlighterFactory, Rope, RopeExt as _,
30+ };
31+ use gpui_kit::component::native_menu::NativeMenu;
32+ use gpui_kit::*;
33+
34+ use crate::ui::metal::{self, Palette};
35+
36+ /// What a byte of Markdown is, as bit flags; a byte can be several at once
37+ /// (bold inside a heading, a delimiter of inline code).
38+ mod flag {
39+ pub const HEADING: u16 = 1 << 0;
40+ pub const STRONG: u16 = 1 << 1;
41+ pub const EMPHASIS: u16 = 1 << 2;
42+ /// Inline code, delimiters included.
43+ pub const CODE: u16 = 1 << 3;
44+ /// A line inside a fenced code block.
45+ pub const CODE_BLOCK: u16 = 1 << 4;
46+ /// The opening or closing line of a fence.
47+ pub const FENCE: u16 = 1 << 5;
48+ /// Text inside a block quote.
49+ pub const QUOTE: u16 = 1 << 6;
50+ /// A list bullet, number or task box.
51+ pub const LIST_MARKER: u16 = 1 << 7;
52+ pub const LINK_TEXT: u16 = 1 << 8;
53+ pub const LINK_URL: u16 = 1 << 9;
54+ /// A leading `/command` or `$skill`.
55+ pub const COMMAND: u16 = 1 << 10;
56+ /// Markdown syntax: `#`, `**`, `` ` ``, `>`, brackets, escapes.
57+ pub const SYNTAX: u16 = 1 << 11;
58+ }
59+
60+ /// A styled run of the document: a byte range and its [`flag`]s.
61+ type Span = (Range<usize>, u16);
62+
63+ thread_local! {
64+ /// The colorway the composer paints with. [`InputHighlighter::styles`]
65+ /// gets no `App`, so [`MarkdownInput`] records the palette here on every
66+ /// render, ahead of the editor's paint in the same frame. One colorway is
67+ /// active app-wide, so one cell serves every composer.
68+ static PALETTE: Cell<Option<&'static Palette>> = const { Cell::new(None) };
69+ }
70+
71+ fn active_palette() -> &'static Palette {
72+ PALETTE.with(Cell::get).unwrap_or(&metal::BONE)
73+ }
74+
75+ /// Turns Markdown into highlight runs for an [`EditorState`].
76+ ///
77+ /// Every edit rescans the whole document. A hand-written line scanner gets
78+ /// through a few hundred lines in microseconds, and a full rescan keeps fenced
79+ /// blocks right however an edit opens or closes them.
80+ #[derive(Default)]
81+ pub struct MarkdownHighlighter {
82+ /// Styled runs over the whole document, ordered and non-overlapping.
83+ /// Unstyled text has no entry.
84+ spans: Vec<Span>,
85+ }
86+
87+ impl MarkdownHighlighter {
88+ pub fn new() -> Self {
89+ Self::default()
90+ }
91+
92+ fn rescan(&mut self, text: &str) {
93+ self.spans = scan(text);
94+ }
95+
96+ /// The highlight runs for `range`, styled with `palette`.
97+ fn styles_with(&self, range: &Range<usize>, palette: &Palette) -> Vec<(Range<usize>, HighlightStyle)> {
98+ let mut runs = Vec::new();
99+ let mut at = range.start;
100+ let first = self.spans.partition_point(|(span, _)| span.end <= range.start);
101+ for (span, flags) in &self.spans[first..] {
102+ if span.start >= range.end {
103+ break;
104+ }
105+ let start = span.start.max(range.start);
106+ let end = span.end.min(range.end);
107+ if start > at {
108+ runs.push((at..start, HighlightStyle::default()));
109+ }
110+ runs.push((start..end, style_for(*flags, palette)));
111+ at = end;
112+ }
113+ if at < range.end {
114+ runs.push((at..range.end, HighlightStyle::default()));
115+ }
116+ runs
117+ }
118+ }
119+
120+ impl InputHighlighter for MarkdownHighlighter {
121+ fn language(&self) -> SharedString {
122+ "markdown".into()
123+ }
124+
125+ fn update(
126+ &mut self,
127+ _edit: Option<InputEdit>,
128+ text: &Rope,
129+ _folding: bool,
130+ _window: &mut Window,
131+ cx: &mut Context<EditorState>,
132+ ) {
133+ PALETTE.with(|cell| cell.set(Some(metal::palette(cx))));
134+ self.rescan(&text.to_string());
135+ }
136+
137+ fn update_batch(
138+ &mut self,
139+ edits: &[(InputEdit, Rope)],
140+ _folding: bool,
141+ _window: &mut Window,
142+ _cx: &mut Context<EditorState>,
143+ ) {
144+ // Only the text after the last edit matters to a full rescan.
145+ if let Some((_, text)) = edits.last() {
146+ self.rescan(&text.to_string());
147+ }
148+ }
149+
150+ fn styles(
151+ &self,
152+ range: &Range<usize>,
153+ _resolver: &dyn HighlightStyleResolver,
154+ ) -> Vec<(Range<usize>, HighlightStyle)> {
155+ // The resolver is gpui-component's syntax theme, which the `Editor`
156+ // element re-installs on every render, so the colors come from the
157+ // MetalUI palette instead.
158+ self.styles_with(range, active_palette())
159+ }
160+
161+ fn fold_ranges(&self, _text: &Rope) -> Vec<FoldRange> {
162+ Vec::new()
163+ }
164+ }
165+
166+ /// The highlighter factory for an [`EditorState`]: Markdown when `on`, plain
167+ /// text otherwise. Installing a factory (even the plain one) also keeps
168+ /// gpui-component's tree-sitter factory out.
169+ pub fn highlighter_factory(on: bool) -> InputHighlighterFactory {
170+ if on {
171+ Rc::new(|_| Some(Box::new(MarkdownHighlighter::new()) as Box<dyn InputHighlighter>))
172+ } else {
173+ Rc::new(|_| None)
174+ }
175+ }
176+
177+ /// Dresses a fresh [`EditorState`] as the composer: no line numbers, gutter,
178+ /// folding or indent guides, no bracket pairing or smart indent, no search
179+ /// panel or scrolling past the end, soft wrap on, and Markdown styling when
180+ /// `rich`. Chain `submit_on_enter` and `placeholder` after it as for a
181+ /// `TextareaState`, and render it with [`MarkdownInput`].
182+ pub fn composer_state(state: EditorState, rich: bool) -> EditorState {
183+ let mut state = state
184+ .line_number(false)
185+ .folding(false)
186+ .indent_guides(false)
187+ .auto_close(false)
188+ .smart_indent(false)
189+ .soft_wrap(true)
190+ .searchable(false)
191+ .scroll_beyond_last_line(Some(0));
192+ state.ensure_highlighter_factory(highlighter_factory(rich));
193+ state
194+ }
195+
196+ /// Turns Markdown styling on or off, live.
197+ pub fn enable(state: &Entity<EditorState>, on: bool, cx: &mut App) {
198+ state.update(cx, |state, cx| state.set_highlighter_factory(highlighter_factory(on), cx));
199+ }
200+
201+ /// Vertical padding the input puts around its text: gpui-component's
202+ /// `Size::Medium.input_py()`, the same as the plain composer's.
203+ const PADDING_Y: f32 = 8.;
204+ /// How much further right an editor starts its text than a textarea: it pads
205+ /// 6px instead of 10px, plus a 6px gap where the line numbers would end.
206+ const EDITOR_INDENT: f32 = 2.;
207+
208+ /// Renders a [`composer_state`] editor as the plain composer would render its
209+ /// `Textarea`: borderless, in the UI font at the textarea's size and leading,
210+ /// growing with its content between `rows` (default 2 to 12).
211+ #[derive(IntoElement)]
212+ pub struct MarkdownInput {
213+ state: Entity<EditorState>,
214+ min_rows: usize,
215+ max_rows: usize,
216+ }
217+
218+ impl MarkdownInput {
219+ pub fn new(state: &Entity<EditorState>) -> Self {
220+ Self { state: state.clone(), min_rows: 2, max_rows: 12 }
221+ }
222+
223+ /// Grow with the content from `min` through `max` rows.
224+ pub fn rows(mut self, min: usize, max: usize) -> Self {
225+ self.min_rows = min.max(1);
226+ self.max_rows = max.max(self.min_rows);
227+ self
228+ }
229+ }
230+
231+ impl RenderOnce for MarkdownInput {
232+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
233+ PALETTE.with(|cell| cell.set(Some(metal::palette(cx))));
234+
235+ let (min, max) = (self.min_rows, self.max_rows);
236+ let state = self.state.read(cx);
237+ let line_height = state.line_height().unwrap_or_else(|| rems(1.25).to_pixels(window.rem_size()));
238+ let rows = content_rows(state).unwrap_or(min).clamp(min, max);
239+
240+ // The rows above come from the last layout. Once this frame has laid
241+ // the text out, measure again and redraw if the content grew or shrank.
242+ let entity = self.state.clone();
243+ window.on_next_frame(move |_, cx| {
244+ let measured = content_rows(entity.read(cx)).map(|now| now.clamp(min, max));
245+ if measured.is_some_and(|now| now != rows) {
246+ cx.notify(entity.entity_id());
247+ }
248+ });
249+
250+ let menu_state = self.state.clone();
251+ Editor::new(&self.state)
252+ .appearance(false)
253+ .h(line_height * rows as f32 + px(PADDING_Y * 2.))
254+ .context_menu(move |menu, _, cx| context_menu(&menu_state, menu, cx))
255+ .font_family(metal::SANS)
256+ .text_size(rems(0.875))
257+ .line_height(rems(1.25))
258+ .ml(px(-EDITOR_INDENT))
259+ }
260+ }
261+
262+ /// The plain text menu. The editor's own adds code navigation entries.
263+ fn context_menu(state: &Entity<EditorState>, menu: NativeMenu, cx: &App) -> NativeMenu {
264+ let state = state.read(cx);
265+ let editable = state.is_editable();
266+ let selected = !state.selected_range().is_empty();
267+ menu.menu_with_disabled("Cut", !(editable && selected), Box::new(input::Cut))
268+ .menu_with_disabled("Copy", !selected, Box::new(input::Copy))
269+ .menu_with_disabled("Paste", !editable, Box::new(input::Paste))
270+ .separator()
271+ .menu("Select All", Box::new(input::SelectAll))
272+ }
273+
274+ /// How many rows the editor's text takes up, soft wraps included, from its
275+ /// last layout. `None` before the first layout.
276+ ///
277+ /// The laid-out lines are measured exactly; lines scrolled out of view count
278+ /// one row each, which only ever undercounts, so growing converges in a frame
279+ /// or two.
280+ fn content_rows(state: &EditorState) -> Option<usize> {
281+ let line_height = state.line_height()?;
282+ let text = state.text();
283+ let lines = text.lines_len().max(1);
284+ let visible = state.visible_row_range()?;
285+ let visible = visible.start.min(lines)..visible.end.min(lines);
286+ if visible.is_empty() {
287+ return None;
288+ }
289+ let start = text.line_start_offset(visible.start);
290+ let end = text.line_end_offset(visible.end - 1);
291+ let top = state.range_to_bounds(&(start..start))?.top();
292+ let bottom = state.range_to_bounds(&(end..end))?.bottom();
293+ let measured = ((bottom - top) / line_height).round().max(1.) as usize;
294+ Some(measured + lines - visible.len())
295+ }
296+
297+ fn style_for(flags: u16, p: &Palette) -> HighlightStyle {
298+ let mut style = HighlightStyle::default();
299+ if flags & (flag::HEADING | flag::STRONG) != 0 {
300+ style.font_weight = Some(FontWeight::BOLD);
301+ }
302+ if flags & flag::EMPHASIS != 0 {
303+ // Geist ships no italic face, and GPUI on Linux does not slant one,
304+ // so emphasis also gets a softer ink to stay visible.
305+ style.font_style = Some(FontStyle::Italic);
306+ style.color = Some(p.ink2);
307+ }
308+ if flags & flag::QUOTE != 0 {
309+ style.color = Some(p.ink2);
310+ }
311+ if flags & flag::LIST_MARKER != 0 {
312+ style.color = Some(p.amber_ink);
313+ }
314+ if flags & flag::COMMAND != 0 {
315+ style.color = Some(p.green_ink);
316+ style.font_weight = Some(FontWeight::MEDIUM);
317+ }
318+ if flags & flag::LINK_TEXT != 0 {
319+ style.color = Some(p.blue_ink);
320+ style.underline =
321+ Some(UnderlineStyle { thickness: px(1.), color: Some(p.blue_ink.opacity(0.5)), wavy: false });
322+ }
323+ if flags & flag::LINK_URL != 0 {
324+ style.color = Some(p.ink3);
325+ }
326+ if flags & flag::CODE != 0 {
327+ style.color = Some(p.ink2);
328+ style.background_color = Some(p.well_top);
329+ }
330+ if flags & flag::SYNTAX != 0 {
331+ style.color = Some(p.ink3);
332+ }
333+ if flags & flag::CODE_BLOCK != 0 {
334+ style.color = Some(p.ink2);
335+ style.font_weight = None;
336+ style.font_style = None;
337+ }
338+ if flags & flag::FENCE != 0 {
339+ style.color = Some(p.ink3);
340+ }
341+ style
342+ }
343+
344+ // ---------------------------------------------------------------------------
345+ // The scanner
346+ // ---------------------------------------------------------------------------
347+
348+ /// An open code fence: its character and length.
349+ #[derive(Clone, Copy)]
350+ struct Fence {
351+ ch: u8,
352+ len: usize,
353+ }
354+
355+ /// Scans `text` into styled runs: ordered, non-overlapping, on UTF-8 char
356+ /// boundaries, unstyled text left out.
357+ fn scan(text: &str) -> Vec<Span> {
358+ let mut spans = Vec::new();
359+ let mut fence = None;
360+ let mut leading = true;
361+ let mut flags = Vec::new();
362+ let mut literal = Vec::new();
363+ let mut offset = 0;
364+ for raw in text.split('\n') {
365+ let line = raw.strip_suffix('\r').unwrap_or(raw);
366+ flags.clear();
367+ flags.resize(line.len(), 0u16);
368+ literal.clear();
369+ literal.resize(line.len(), false);
370+ // The message is sent trimmed, so its first non-blank line leads.
371+ let leads = leading && !line.trim().is_empty();
372+ scan_line(line, &mut fence, leads, &mut flags, &mut literal);
373+ leading &= !leads;
374+ push_runs(&mut spans, offset, &flags);
375+ offset += raw.len() + 1;
376+ }
377+ spans
378+ }
379+
380+ /// Appends the runs of equal, non-zero flags in one line.
381+ fn push_runs(spans: &mut Vec<Span>, offset: usize, flags: &[u16]) {
382+ let mut start = 0;
383+ while start < flags.len() {
384+ let value = flags[start];
385+ let mut end = start + 1;
386+ while end < flags.len() && flags[end] == value {
387+ end += 1;
388+ }
389+ if value != 0 {
390+ spans.push((offset + start..offset + end, value));
391+ }
392+ start = end;
393+ }
394+ }
395+
396+ fn mark(flags: &mut [u16], range: Range<usize>, value: u16) {
397+ for slot in &mut flags[range] {
398+ *slot |= value;
399+ }
400+ }
401+
402+ fn indent_of(bytes: &[u8], from: usize) -> usize {
403+ bytes[from..].iter().take_while(|&&b| b == b' ' || b == b'\t').count()
404+ }
405+
406+ fn scan_line(line: &str, fence: &mut Option<Fence>, leads: bool, flags: &mut [u16], literal: &mut [bool]) {
407+ let bytes = line.as_bytes();
408+ let len = bytes.len();
409+
410+ if let Some(open) = *fence {
411+ if closes_fence(bytes, open) {
412+ *fence = None;
413+ mark(flags, 0..len, flag::FENCE);
414+ } else {
415+ mark(flags, 0..len, flag::CODE_BLOCK);
416+ }
417+ return;
418+ }
419+ if let Some(open) = opens_fence(bytes) {
420+ *fence = Some(open);
421+ mark(flags, 0..len, flag::FENCE);
422+ return;
423+ }
424+
425+ let mut pos = 0;
426+ if leads && let Some(end) = leading_command(bytes) {
427+ let start = indent_of(bytes, 0);
428+ mark(flags, start..end, flag::COMMAND);
429+ inline(line, end..len, flags, literal);
430+ return;
431+ }
432+
433+ // Block quotes, nested or not: `>` markers, each with one optional space.
434+ loop {
435+ let at = pos + indent_of(bytes, pos);
436+ if at - pos <= 3 && bytes.get(at) == Some(&b'>') {
437+ mark(flags, at..len, flag::QUOTE);
438+ mark(flags, at..at + 1, flag::SYNTAX);
439+ pos = at + 1;
440+ if bytes.get(pos) == Some(&b' ') {
441+ pos += 1;
442+ }
443+ } else {
444+ break;
445+ }
446+ }
447+
448+ if is_thematic_break(&bytes[pos..]) {
449+ mark(flags, pos..len, flag::SYNTAX);
450+ return;
451+ }
452+
453+ // ATX heading: up to three spaces, one to six `#`, then a space or the end.
454+ let at = pos + indent_of(bytes, pos);
455+ let hashes = bytes[at..].iter().take_while(|&&b| b == b'#').count();
456+ if at - pos <= 3
457+ && (1..=6).contains(&hashes)
458+ && matches!(bytes.get(at + hashes), None | Some(b' ' | b'\t'))
459+ {
460+ mark(flags, at..len, flag::HEADING);
461+ mark(flags, at..at + hashes, flag::SYNTAX);
462+ // An optional closing run of `#` after a space.
463+ let trimmed = line.trim_end().len();
464+ let closing = bytes[..trimmed].iter().rev().take_while(|&&b| b == b'#').count();
465+ let mut content_end = trimmed;
466+ if closing > 0 && trimmed - closing > at + hashes && matches!(bytes[trimmed - closing - 1], b' ' | b'\t') {
467+ content_end = trimmed - closing;
468+ mark(flags, content_end..trimmed, flag::SYNTAX);
469+ }
470+ inline(line, (at + hashes).min(content_end)..content_end, flags, literal);
471+ return;
472+ }
473+
474+ // List item: a bullet or a number, then a space or the end, then
475+ // optionally a task box.
476+ let mut content = pos;
477+ if let Some(marker_end) = list_marker(bytes, at) {
478+ mark(flags, at..marker_end, flag::LIST_MARKER);
479+ content = marker_end;
480+ let box_at = marker_end + indent_of(bytes, marker_end);
481+ if box_at > marker_end
482+ && let [b'[', b' ' | b'x' | b'X', b']', ..] = &bytes[box_at..]
483+ && matches!(bytes.get(box_at + 3), None | Some(b' ' | b'\t'))
484+ {
485+ mark(flags, box_at..box_at + 3, flag::LIST_MARKER);
486+ content = box_at + 3;
487+ }
488+ }
489+ inline(line, content..len, flags, literal);
490+ }
491+
492+ /// An opening fence: three or more backticks or tildes, at any indent (fences
493+ /// inside list items are indented). A backtick fence's info string can't hold
494+ /// a backtick, or it's inline code.
495+ fn opens_fence(bytes: &[u8]) -> Option<Fence> {
496+ let at = indent_of(bytes, 0);
497+ let ch = *bytes.get(at)?;
498+ if ch != b'`' && ch != b'~' {
499+ return None;
500+ }
501+ let len = bytes[at..].iter().take_while(|&&b| b == ch).count();
502+ if len < 3 || (ch == b'`' && bytes[at + len..].contains(&b'`')) {
503+ return None;
504+ }
505+ Some(Fence { ch, len })
506+ }
507+
508+ /// A closing fence: at least as long as the opening one, nothing after it.
509+ fn closes_fence(bytes: &[u8], open: Fence) -> bool {
510+ let at = indent_of(bytes, 0);
511+ let len = bytes[at..].iter().take_while(|&&b| b == open.ch).count();
512+ len >= open.len && bytes[at + len..].iter().all(|b| b.is_ascii_whitespace())
513+ }
514+
515+ /// `---`, `***` or `___`, optionally spaced out.
516+ fn is_thematic_break(bytes: &[u8]) -> bool {
517+ let mut ch = None;
518+ let mut count = 0;
519+ for &b in bytes {
520+ match b {
521+ b' ' | b'\t' => {}
522+ b'-' | b'*' | b'_' if ch.is_none_or(|c| c == b) => {
523+ ch = Some(b);
524+ count += 1;
525+ }
526+ _ => return false,
527+ }
528+ }
529+ count >= 3
530+ }
531+
532+ /// The end of a list marker starting at `at`: `-`, `*` or `+`, or up to nine
533+ /// digits and `.` or `)`, followed by a space or the end of the line.
534+ fn list_marker(bytes: &[u8], at: usize) -> Option<usize> {
535+ let end = match *bytes.get(at)? {
536+ b'-' | b'*' | b'+' => at + 1,
537+ b'0'..=b'9' => {
538+ let digits = bytes[at..].iter().take_while(|b| b.is_ascii_digit()).count();
539+ if digits > 9 || !matches!(bytes.get(at + digits), Some(b'.' | b')')) {
540+ return None;
541+ }
542+ at + digits + 1
543+ }
544+ _ => return None,
545+ };
546+ matches!(bytes.get(end), None | Some(b' ' | b'\t')).then_some(end)
547+ }
548+
549+ /// The end of a `/command` or `$skill` at the start of the line.
550+ fn leading_command(bytes: &[u8]) -> Option<usize> {
551+ let at = indent_of(bytes, 0);
552+ let sigil = *bytes.get(at)?;
553+ if sigil != b'/' && sigil != b'$' {
554+ return None;
555+ }
556+ let name = bytes[at + 1..]
557+ .iter()
558+ .take_while(|&&b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'.'))
559+ .count();
560+ let first = *bytes.get(at + 1)?;
561+ let starts_well = if sigil == b'$' { first.is_ascii_alphabetic() } else { first.is_ascii_alphanumeric() };
562+ let end = at + 1 + name;
563+ (name > 0 && starts_well && matches!(bytes.get(end), None | Some(b' ' | b'\t'))).then_some(end)
564+ }
565+
566+ /// Marks inline Markdown in `range` of `line`: escapes, code spans, links,
567+ /// bare URLs and emphasis, in that order of precedence. `literal` collects
568+ /// bytes that later passes must not read as syntax.
569+ fn inline(line: &str, range: Range<usize>, flags: &mut [u16], literal: &mut [bool]) {
570+ let bytes = line.as_bytes();
571+ let Range { start, end } = range;
572+
573+ // Escapes and code spans, left to right as CommonMark reads them.
574+ let mut i = start;
575+ while i < end {
576+ match bytes[i] {
577+ b'\\' if i + 1 < end && bytes[i + 1].is_ascii_punctuation() => {
578+ mark(flags, i..i + 1, flag::SYNTAX);
579+ literal[i] = true;
580+ literal[i + 1] = true;
581+ i += 2;
582+ }
583+ b'`' => {
584+ let run = run_len(bytes, i, end, b'`');
585+ if let Some(close) = find_backtick_run(bytes, i + run, end, run) {
586+ mark(flags, i..close + run, flag::CODE);
587+ mark(flags, i..i + run, flag::SYNTAX);
588+ mark(flags, close..close + run, flag::SYNTAX);
589+ literal[i..close + run].fill(true);
590+ i = close + run;
591+ } else {
592+ literal[i..i + run].fill(true);
593+ i += run;
594+ }
595+ }
596+ _ => i += 1,
597+ }
598+ }
599+
600+ links(line, start..end, flags, literal);
601+ bare_urls(line, start..end, flags, literal);
602+ emphasis(line, start..end, flags, literal);
603+ }
604+
605+ fn run_len(bytes: &[u8], at: usize, end: usize, ch: u8) -> usize {
606+ bytes[at..end].iter().take_while(|&&b| b == ch).count()
607+ }
608+
609+ /// The start of the next run of exactly `len` backticks in `from..end`.
610+ fn find_backtick_run(bytes: &[u8], from: usize, end: usize, len: usize) -> Option<usize> {
611+ let mut i = from;
612+ while i < end {
613+ if bytes[i] == b'`' {
614+ let run = run_len(bytes, i, end, b'`');
615+ if run == len {
616+ return Some(i);
617+ }
618+ i += run;
619+ } else {
620+ i += 1;
621+ }
622+ }
623+ None
624+ }
625+
626+ /// `[text](url)` and `![alt](url)`.
627+ fn links(line: &str, range: Range<usize>, flags: &mut [u16], literal: &mut [bool]) {
628+ let bytes = line.as_bytes();
629+ let Range { start, end } = range;
630+ let mut i = start;
631+ while i < end {
632+ if bytes[i] != b'[' || literal[i] {
633+ i += 1;
634+ continue;
635+ }
636+ let Some(close) = matching(bytes, literal, i, end, b'[', b']') else {
637+ i += 1;
638+ continue;
639+ };
640+ let Some(paren_close) = (bytes.get(close + 1) == Some(&b'('))
641+ .then(|| matching(bytes, literal, close + 1, end, b'(', b')'))
642+ .flatten()
643+ else {
644+ i += 1;
645+ continue;
646+ };
647+ let open = if i > start && bytes[i - 1] == b'!' && !literal[i - 1] { i - 1 } else { i };
648+ mark(flags, i + 1..close, flag::LINK_TEXT);
649+ mark(flags, close + 2..paren_close, flag::LINK_URL);
650+ mark(flags, open..i + 1, flag::SYNTAX);
651+ mark(flags, close..close + 2, flag::SYNTAX);
652+ mark(flags, paren_close..paren_close + 1, flag::SYNTAX);
653+ for at in [i, close, close + 1, paren_close] {
654+ literal[at] = true;
655+ }
656+ literal[open] = true;
657+ literal[close + 2..paren_close].fill(true);
658+ i = paren_close + 1;
659+ }
660+ }
661+
662+ /// The index of the bracket closing the one at `open`, skipping literal bytes.
663+ fn matching(bytes: &[u8], literal: &[bool], open: usize, end: usize, left: u8, right: u8) -> Option<usize> {
664+ let mut depth = 0usize;
665+ for i in open..end {
666+ if literal[i] && i != open {
667+ continue;
668+ }
669+ if bytes[i] == left {
670+ depth += 1;
671+ } else if bytes[i] == right {
672+ depth -= 1;
673+ if depth == 0 {
674+ return Some(i);
675+ }
676+ }
677+ }
678+ None
679+ }
680+
681+ /// `http://` and `https://` URLs written out in the text.
682+ fn bare_urls(line: &str, range: Range<usize>, flags: &mut [u16], literal: &mut [bool]) {
683+ let bytes = line.as_bytes();
684+ let mut search = range.start;
685+ while let Some(found) = line[search..range.end].find("http") {
686+ let at = search + found;
687+ let rest = &line[at..range.end];
688+ let scheme = if rest.starts_with("https://") {
689+ 8
690+ } else if rest.starts_with("http://") {
691+ 7
692+ } else {
693+ search = at + 4;
694+ continue;
695+ };
696+ let word_start = at == range.start || matches!(bytes[at - 1], b' ' | b'\t' | b'(' | b'<' | b'"' | b'\'');
697+ if !word_start || literal[at] {
698+ search = at + scheme;
699+ continue;
700+ }
701+ let mut end = at + rest.find(|c: char| c.is_whitespace() || c == '<' || c == '>').unwrap_or(rest.len());
702+ while end > at + scheme && matches!(bytes[end - 1], b'.' | b',' | b':' | b';' | b'!' | b'?' | b')' | b'\'' | b'"') {
703+ end -= 1;
704+ }
705+ if end > at + scheme {
706+ mark(flags, at..end, flag::LINK_TEXT);
707+ literal[at..end].fill(true);
708+ }
709+ search = end.max(at + scheme);
710+ }
711+ }
712+
713+ /// A run of `*` or `_` that may open or close emphasis.
714+ struct Delimiter {
715+ ch: u8,
716+ /// Where the run's unused delimiters start.
717+ start: usize,
718+ /// How many are left unused.
719+ len: usize,
720+ original_len: usize,
721+ can_open: bool,
722+ can_close: bool,
723+ }
724+
725+ fn is_punctuation(c: char) -> bool {
726+ c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control())
727+ }
728+
729+ /// `*emphasis*`, `_emphasis_`, `**strong**` and `__strong__`, matched with
730+ /// CommonMark's delimiter-run rules (flanking, intraword `_`, rule of three).
731+ fn emphasis(line: &str, range: Range<usize>, flags: &mut [u16], literal: &[bool]) {
732+ let bytes = line.as_bytes();
733+ let mut runs: Vec<Delimiter> = Vec::new();
734+ let mut i = range.start;
735+ while i < range.end {
736+ let ch = bytes[i];
737+ if (ch != b'*' && ch != b'_') || literal[i] {
738+ i += 1;
739+ continue;
740+ }
741+ let mut len = 1;
742+ while i + len < range.end && bytes[i + len] == ch && !literal[i + len] {
743+ len += 1;
744+ }
745+ let before = line[..i].chars().next_back().unwrap_or(' ');
746+ let after = line[i + len..].chars().next().unwrap_or(' ');
747+ let left = !after.is_whitespace()
748+ && (!is_punctuation(after) || before.is_whitespace() || is_punctuation(before));
749+ let right = !before.is_whitespace()
750+ && (!is_punctuation(before) || after.is_whitespace() || is_punctuation(after));
751+ let (can_open, can_close) = if ch == b'*' {
752+ (left, right)
753+ } else {
754+ (left && (!right || is_punctuation(before)), right && (!left || is_punctuation(after)))
755+ };
756+ if can_open || can_close {
757+ runs.push(Delimiter { ch, start: i, len, original_len: len, can_open, can_close });
758+ }
759+ i += len;
760+ }
761+
762+ for closer in 0..runs.len() {
763+ if !runs[closer].can_close {
764+ continue;
765+ }
766+ while runs[closer].len > 0 {
767+ let opener = (0..closer).rev().find(|&o| {
768+ let (o, c) = (&runs[o], &runs[closer]);
769+ let odd_match = (o.can_close || c.can_open)
770+ && (o.original_len + c.original_len) % 3 == 0
771+ && !(o.original_len % 3 == 0 && c.original_len % 3 == 0);
772+ o.ch == c.ch && o.can_open && o.len > 0 && !odd_match
773+ });
774+ let Some(opener) = opener else { break };
775+ let used = if runs[opener].len >= 2 && runs[closer].len >= 2 { 2 } else { 1 };
776+ let open_end = runs[opener].start + runs[opener].len;
777+ let close_start = runs[closer].start;
778+ mark(flags, open_end - used..open_end, flag::SYNTAX);
779+ mark(flags, close_start..close_start + used, flag::SYNTAX);
780+ mark(flags, open_end..close_start, if used == 2 { flag::STRONG } else { flag::EMPHASIS });
781+ runs[opener].len -= used;
782+ runs[closer].start += used;
783+ runs[closer].len -= used;
784+ for between in &mut runs[opener + 1..closer] {
785+ between.len = 0;
786+ }
787+ }
788+ }
789+ }
790+
791+ #[cfg(test)]
792+ mod tests {
793+ // Not `super::*`: GPUI's glob exports its own `test` attribute.
794+ use std::ops::Range;
795+
796+ use gpui_kit::{FontWeight, HighlightStyle};
797+
798+ use super::{MarkdownHighlighter, flag, metal, scan};
799+
800+ /// The flags of the bytes covered by `needle`'s first occurrence, which
801+ /// must all agree.
802+ fn flags_of(text: &str, needle: &str) -> u16 {
803+ let at = text.find(needle).unwrap_or_else(|| panic!("{needle:?} not in {text:?}"));
804+ flags_at(text, at..at + needle.len())
805+ }
806+
807+ fn flags_at(text: &str, range: Range<usize>) -> u16 {
808+ let spans = scan(text);
809+ let value = |offset: usize| {
810+ spans.iter().find(|(span, _)| span.contains(&offset)).map_or(0, |(_, flags)| *flags)
811+ };
812+ let first = value(range.start);
813+ for offset in range.clone() {
814+ assert_eq!(value(offset), first, "mixed flags in {:?} of {text:?}", &text[range.clone()]);
815+ }
816+ first
817+ }
818+
819+ fn assert_well_formed(text: &str) {
820+ let spans = scan(text);
821+ let mut last = 0;
822+ for (span, flags) in &spans {
823+ assert!(span.start >= last && span.start < span.end, "{spans:?}");
824+ assert!(*flags != 0);
825+ assert!(text.is_char_boundary(span.start) && text.is_char_boundary(span.end), "{span:?}");
826+ assert!(span.end <= text.len());
827+ last = span.end;
828+ }
829+ }
830+
831+ /// `styles` must cover any requested range exactly, in order.
832+ fn assert_covers(text: &str, range: Range<usize>) {
833+ let mut highlighter = MarkdownHighlighter::new();
834+ highlighter.rescan(text);
835+ let runs = highlighter.styles_with(&range, &metal::BONE);
836+ let mut at = range.start;
837+ for (run, _) in &runs {
838+ assert_eq!(run.start, at, "gap or overlap in {runs:?}");
839+ assert!(run.end > run.start, "empty run in {runs:?}");
840+ assert!(text.is_char_boundary(run.start) && text.is_char_boundary(run.end));
841+ at = run.end;
842+ }
843+ assert_eq!(at, range.end, "runs end early: {runs:?}");
844+ }
845+
846+ #[test]
847+ fn headings() {
848+ let text = "# Title\n###### Six\n####### seven\n#hashtag\n## Closed ##";
849+ assert_eq!(flags_of(text, "#"), flag::HEADING | flag::SYNTAX);
850+ assert_eq!(flags_of(text, "Title"), flag::HEADING);
851+ assert_eq!(flags_of(text, "Six"), flag::HEADING);
852+ assert_eq!(flags_of(text, "seven"), 0);
853+ assert_eq!(flags_of(text, "#hashtag"), 0);
854+ assert_eq!(flags_of(text, "Closed"), flag::HEADING);
855+ let closing = text.rfind("##").unwrap();
856+ assert_eq!(flags_at(text, closing..closing + 2), flag::HEADING | flag::SYNTAX);
857+ assert_eq!(flags_of("# **Bold** head", "Bold"), flag::HEADING | flag::STRONG);
858+ }
859+
860+ #[test]
861+ fn emphasis_and_strong() {
862+ let text = "a *one* b **two** c _three_ d __four__ e ***five***";
863+ assert_eq!(flags_of(text, "one"), flag::EMPHASIS);
864+ assert_eq!(flags_of(text, "two"), flag::STRONG);
865+ assert_eq!(flags_of(text, "three"), flag::EMPHASIS);
866+ assert_eq!(flags_of(text, "four"), flag::STRONG);
867+ assert_eq!(flags_of(text, "five"), flag::STRONG | flag::EMPHASIS);
868+ assert_eq!(flags_of(text, "**"), flag::SYNTAX);
869+ assert_eq!(flags_of(text, " b "), 0);
870+ }
871+
872+ #[test]
873+ fn emphasis_that_is_not() {
874+ // Intraword underscores, spaced-out stars and unclosed runs stay text.
875+ for text in ["snake_case_name", "2 * 3 * 4", "a **b", "* not a list*either", "x__y__z"] {
876+ let spans = scan(text);
877+ assert!(spans.iter().all(|(_, flags)| flags & (flag::EMPHASIS | flag::STRONG) == 0), "{text:?}: {spans:?}");
878+ }
879+ // Stars inside a word still do.
880+ assert_eq!(flags_of("un*frigging*believable", "frigging"), flag::EMPHASIS);
881+ }
882+
883+ #[test]
884+ fn nested_emphasis() {
885+ let text = "**bold *both* bold**";
886+ assert_eq!(flags_of(text, "both"), flag::STRONG | flag::EMPHASIS);
887+ assert_eq!(flags_of(text, "bold "), flag::STRONG);
888+ }
889+
890+ #[test]
891+ fn inline_code() {
892+ let text = "run `cargo *test*` now and ``a ` b``";
893+ assert_eq!(flags_of(text, "cargo *test*"), flag::CODE);
894+ assert_eq!(flags_of(text, "`"), flag::CODE | flag::SYNTAX);
895+ assert_eq!(flags_of(text, "a ` b"), flag::CODE);
896+ assert_eq!(flags_of(text, " now "), 0);
897+ // An unmatched backtick is just a backtick.
898+ assert_eq!(scan("a ` b"), vec![]);
899+ }
900+
901+ #[test]
902+ fn escapes() {
903+ let text = r"\*not emphasis\* and \`not code\`";
904+ assert_eq!(flags_of(text, "not emphasis"), 0);
905+ assert_eq!(flags_of(text, "not code"), 0);
906+ assert_eq!(flags_at(text, 0..1), flag::SYNTAX);
907+ }
908+
909+ /// The flags of line `n`, which must agree across it.
910+ fn line_flags(text: &str, n: usize) -> u16 {
911+ let start: usize = text.split('\n').take(n).map(|line| line.len() + 1).sum();
912+ let line = text.split('\n').nth(n).unwrap();
913+ flags_at(text, start..start + line.len())
914+ }
915+
916+ #[test]
917+ fn fences_span_lines() {
918+ let text = "before\n```rust\nlet x = *y*;\n# not a heading\n```\nafter *em*";
919+ assert_eq!(line_flags(text, 0), 0);
920+ assert_eq!(line_flags(text, 1), flag::FENCE);
921+ assert_eq!(line_flags(text, 2), flag::CODE_BLOCK);
922+ assert_eq!(line_flags(text, 3), flag::CODE_BLOCK);
923+ assert_eq!(line_flags(text, 4), flag::FENCE);
924+ assert_eq!(flags_of(text, "em"), flag::EMPHASIS);
925+ }
926+
927+ #[test]
928+ fn fences_need_a_matching_close() {
929+ // A shorter run or another character doesn't close; an unclosed fence
930+ // runs to the end, as it does on GitHub.
931+ let text = "~~~~\n~~~\n```\nstill code\n~~~~~\nout";
932+ assert_eq!(line_flags(text, 0), flag::FENCE);
933+ assert_eq!(line_flags(text, 1), flag::CODE_BLOCK);
934+ assert_eq!(line_flags(text, 2), flag::CODE_BLOCK);
935+ assert_eq!(line_flags(text, 3), flag::CODE_BLOCK);
936+ assert_eq!(line_flags(text, 4), flag::FENCE);
937+ assert_eq!(line_flags(text, 5), 0);
938+ assert_eq!(line_flags("```\nopen\nto the end", 2), flag::CODE_BLOCK);
939+ // Backticks in the info string make it inline code, not a fence.
940+ assert_eq!(flags_of("```a``` b", "b"), 0);
941+ assert_eq!(flags_of("```a``` b", "a"), flag::CODE);
942+ // Indented fences count, as inside list items.
943+ assert_eq!(flags_of("1. step\n ```\n code\n ```\nout", "code"), flag::CODE_BLOCK);
944+ assert_eq!(flags_of("1. step\n ```\n code\n ```\nout", "out"), 0);
945+ }
946+
947+ #[test]
948+ fn quotes_lists_and_rules() {
949+ let text = "> quoted *em*";
950+ assert_eq!(flags_of(text, ">"), flag::QUOTE | flag::SYNTAX);
951+ assert_eq!(flags_of(text, "quoted"), flag::QUOTE);
952+ assert_eq!(flags_of(text, "em"), flag::QUOTE | flag::EMPHASIS);
953+ for (text, marker) in
954+ [("- item", "-"), ("* star", "*"), ("+ plus", "+"), ("12. twelve", "12."), ("3) three", "3)"), (" - nested", "-")]
955+ {
956+ assert_eq!(flags_of(text, marker), flag::LIST_MARKER, "{text:?}");
957+ assert_eq!(flags_at(text, text.len() - 3..text.len()), 0, "{text:?}");
958+ }
959+ assert_eq!(flags_of("- [x] done", "[x]"), flag::LIST_MARKER);
960+ assert_eq!(flags_of("- [x] done", "done"), 0);
961+ assert_eq!(flags_of("---", "---"), flag::SYNTAX);
962+ assert_eq!(flags_of("* * *", "* * *"), flag::SYNTAX);
963+ assert_eq!(flags_of("-not", "-not"), 0);
964+ assert_eq!(flags_of("1.5 times", "1.5"), 0);
965+ assert_eq!(flags_of("> - nested", "-"), flag::QUOTE | flag::LIST_MARKER);
966+ }
967+
968+ #[test]
969+ fn links_and_urls() {
970+ let text = "see [the docs](https://x.dev/a_b_c) and ![img](p.png) or https://t3.chat/x.";
971+ assert_eq!(flags_of(text, "the docs"), flag::LINK_TEXT);
972+ assert_eq!(flags_of(text, "https://x.dev/a_b_c"), flag::LINK_URL);
973+ assert_eq!(flags_of(text, "]("), flag::SYNTAX);
974+ assert_eq!(flags_of(text, "![") , flag::SYNTAX);
975+ assert_eq!(flags_of(text, "https://t3.chat/x"), flag::LINK_TEXT);
976+ assert_eq!(flags_at(text, text.len() - 1..text.len()), 0);
977+ assert_eq!(flags_of("[not a link] (x)", "not a link"), 0);
978+ }
979+
980+ #[test]
981+ fn leading_commands() {
982+ assert_eq!(flags_of("/review this *now*", "/review"), flag::COMMAND);
983+ assert_eq!(flags_of("/review this *now*", "now"), flag::EMPHASIS);
984+ assert_eq!(flags_of("\n $deploy-prod please", "$deploy-prod"), flag::COMMAND);
985+ assert_eq!(flags_of("/plugin:cmd", "/plugin:cmd"), flag::COMMAND);
986+ // Not leading, a path, or a price: plain text.
987+ assert_eq!(flags_of("hi\n/review", "/review"), 0);
988+ assert_eq!(flags_of("/home/joni is mine", "/home"), 0);
989+ assert_eq!(flags_of("$5 please", "$5"), 0);
990+ }
991+
992+ #[test]
993+ fn unicode_text() {
994+ let text = "# Grüße 👋\n**fett** und *kursiv* — `naïve`\n> ünïcödé [lïnk](ü)\n日本語の*強調*です";
995+ assert_well_formed(text);
996+ assert_eq!(flags_of(text, "Grüße 👋"), flag::HEADING);
997+ assert_eq!(flags_of(text, "fett"), flag::STRONG);
998+ assert_eq!(flags_of(text, "kursiv"), flag::EMPHASIS);
999+ assert_eq!(flags_of(text, "naïve"), flag::CODE);
1000+ assert_eq!(flags_of(text, "lïnk"), flag::QUOTE | flag::LINK_TEXT);
1001+ assert_eq!(flags_of(text, "強調"), flag::EMPHASIS);
1002+ for end in (0..=text.len()).filter(|&i| text.is_char_boundary(i)) {
1003+ assert_covers(text, 0..end);
1004+ assert_covers(text, end..text.len());
1005+ }
1006+ }
1007+
1008+ #[test]
1009+ fn styles_cover_every_line_range() {
1010+ let text = "# H\n\n- a **b** `c`\r\n```\nx\n```\n> q [l](u)\n__u__ *e* ~ end";
1011+ assert_well_formed(text);
1012+ let mut starts = vec![0];
1013+ starts.extend(text.match_indices('\n').map(|(i, _)| i + 1));
1014+ starts.push(text.len());
1015+ for (i, &start) in starts.iter().enumerate() {
1016+ for &end in &starts[i..] {
1017+ assert_covers(text, start..end);
1018+ }
1019+ }
1020+ assert_covers("", 0..0);
1021+ assert!(MarkdownHighlighter::new().styles_with(&(0..0), &metal::BONE).is_empty());
1022+ }
1023+
1024+ #[test]
1025+ fn crlf_lines() {
1026+ let text = "**a**\r\n# b\r\n";
1027+ assert_well_formed(text);
1028+ assert_eq!(flags_of(text, "a"), flag::STRONG);
1029+ assert_eq!(flags_of(text, "b"), flag::HEADING);
1030+ assert_eq!(flags_of(text, "\r"), 0);
1031+ }
1032+
1033+ #[test]
1034+ fn styles_follow_flags() {
1035+ let mut highlighter = MarkdownHighlighter::new();
1036+ let text = "**b** `c`";
1037+ highlighter.rescan(text);
1038+ let runs = highlighter.styles_with(&(0..text.len()), &metal::GRAPHITE);
1039+ let style_at = |offset: usize| runs.iter().find(|(run, _)| run.contains(&offset)).unwrap().1;
1040+ assert_eq!(style_at(2).font_weight, Some(FontWeight::BOLD));
1041+ assert_eq!(style_at(0).color, Some(metal::GRAPHITE.ink3));
1042+ assert_eq!(style_at(7).background_color, Some(metal::GRAPHITE.well_top));
1043+ assert_eq!(style_at(5), HighlightStyle::default());
1044+ }
1045+
1046+ #[test]
1047+ fn a_few_hundred_lines_scan_fast() {
1048+ let block = "# Heading\nSome *text* with **bold**, `code` and [a link](https://x.y).\n- item one\n```\nfn main() {}\n```\n> quote\n";
1049+ let text = block.repeat(100);
1050+ let started = std::time::Instant::now();
1051+ for _ in 0..10 {
1052+ assert!(!scan(&text).is_empty());
1053+ }
1054+ assert!(started.elapsed() < std::time::Duration::from_millis(200), "{:?}", started.elapsed());
1055+ }
1056+ }
+1−0src/ui/mod.rsView File
@@ -1,6 +1,7 @@
11 pub mod changes;
22 pub mod chat;
33 pub mod icons;
4+ pub mod markdown_input;
45 pub mod metal;
56 pub mod pickers;
67 pub mod settings;