feat: enlarge floating hover and highlight Markdown content

This commit is contained in:
2026-10-10 16:10:25 +02:00
parent 99028daa9d
commit dc0bdd60d0
5 changed files with 123 additions and 15 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the asc
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport.
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
## Terminal lifecycle and layout
+1 -1
View File
@@ -199,7 +199,7 @@ Diagnostics appear as severity-colored gutter numbers and text underlines; the p
`:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it.
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. Multiline content is preserved; Markdown is shown as plain text, not rendered markup. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 24 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
Semantic highlighting is **enabled automatically** when the first server advertises [LSP semantic tokens](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens). No additional configuration or binding is needed. Full-document requests are preferred; range-only servers receive a whole-document range request. Token colors override Tree-sitter where recognized; unsupported servers, unknown custom token types, missing tokens, and failed replies retain syntax highlighting. Text/path changes clear old semantic colors immediately, and stale replies cannot restore them. Same-text refreshes keep the current overlay until the replacement arrives.
+1 -1
View File
@@ -40,7 +40,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state.
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets.
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
+12
View File
@@ -64,6 +64,18 @@ impl SyntaxCache {
Self::default()
}
pub(crate) fn update_text(&mut self, text: &str, path: &Path) {
if self.source == text && self.path.as_deref() == Some(path) {
return;
}
let lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
self.update(
&lines,
Some(path),
self.revision.unwrap_or(0).wrapping_add(1),
);
}
/// `revision` must change whenever line content changes; path changes are detected separately.
pub fn update(&mut self, lines: &[String], path: Option<&Path>, revision: u64) {
let path_changed = self.path.as_deref() != path;
+108 -12
View File
@@ -567,16 +567,7 @@ fn draw(
spans
.get(span_index)
.filter(|span| span.start_byte <= byte)
.map(|span| {
Style::default().fg(match span.kind {
SyntaxKind::Comment => Color::DarkGray,
SyntaxKind::String => Color::Green,
SyntaxKind::Keyword => Color::Magenta,
SyntaxKind::Number => Color::Yellow,
SyntaxKind::Type | SyntaxKind::Markup => Color::Cyan,
SyntaxKind::Function => Color::LightBlue,
})
})
.map(|span| syntax_style(span.kind))
});
if let Some(style) = style
&& end > *left
@@ -907,6 +898,7 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
#[derive(Default)]
struct WheelPanels {
hover: Option<(Rect, usize)>,
hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>,
}
@@ -1037,7 +1029,7 @@ fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPa
}
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(area.width.min(84)),
Constraint::Length(area.width.min(100)),
Constraint::Fill(1),
])
.areas(area);
@@ -1045,7 +1037,7 @@ fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPa
Constraint::Fill(1),
Constraint::Length(
area.height
.min(hover.lines().len().saturating_add(3).min(18) as u16),
.min(hover.lines().len().saturating_add(3).min(24) as u16),
),
Constraint::Fill(1),
])
@@ -1083,6 +1075,50 @@ fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPa
})
.collect();
frame.render_widget(Paragraph::new(lines), text_area);
panels
.hover_syntax
.update_text(hover.text(), std::path::Path::new("hover.md"));
let width = usize::from(text_area.width);
for (row, line) in hover
.lines()
.enumerate()
.skip(hover.top)
.take(usize::from(text_area.height))
{
let spans = panels.hover_syntax.line(row);
if spans.is_empty() {
continue;
}
let left = display_column(line, hover.left);
let right = left.saturating_add(width);
let mut column = 0;
let mut span_index = 0;
for (byte, grapheme) in line.grapheme_indices(true) {
if column >= right {
break;
}
let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
};
let end = column + cells;
while span_index < spans.len() && spans[span_index].end_byte <= byte {
span_index += 1;
}
if let Some(span) = spans.get(span_index).filter(|span| span.start_byte <= byte)
&& end > left
&& (grapheme == "\t" || (column >= left && end <= right))
{
let style = syntax_style(span.kind);
let y = text_area.y + (row - hover.top) as u16;
for cell in column.max(left)..end.min(right) {
frame.buffer_mut()[(text_area.x + (cell - left) as u16, y)].set_style(style);
}
}
column = end;
}
}
frame.render_widget(
Paragraph::new("Arrows/Ctrl+h/j/k/l scroll · Esc close")
.style(Style::default().fg(Color::DarkGray)),
@@ -1401,6 +1437,17 @@ fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode, navigation_hin
);
}
fn syntax_style(kind: SyntaxKind) -> Style {
Style::default().fg(match kind {
SyntaxKind::Comment => Color::DarkGray,
SyntaxKind::String => Color::Green,
SyntaxKind::Keyword => Color::Magenta,
SyntaxKind::Number => Color::Yellow,
SyntaxKind::Type | SyntaxKind::Markup => Color::Cyan,
SyntaxKind::Function => Color::LightBlue,
})
}
fn semantic_style(token: &SemanticToken) -> Style {
use SemanticTokenKind::*;
let readonly = token.modifiers & SemanticTokenModifier::Readonly as u16 != 0;
@@ -1482,6 +1529,55 @@ mod tests {
.join("\n")
}
#[test]
fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() {
let mut editor = Editor::open(None, vec![]).unwrap();
let title = format!("# {}TAIL", "Heading ".repeat(11));
let details = (5..36)
.map(|row| format!("detail-{row:02}"))
.collect::<Vec<_>>()
.join("\n");
editor
.show_hover(format!(
"{title}\n\n```rust\nfn answer() -> i32 {{ 42 }}\n```\n{details}"
))
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 32)).unwrap();
let mut panels = WheelPanels::default();
terminal
.draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels))
.unwrap();
assert!(screen_text(&terminal).contains("TAIL"));
assert!(screen_text(&terminal).contains("detail-20"));
let (panel, _) = panels.hover.unwrap();
let (x, y) = (panel.x + 1, panel.y + 1);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, y)].fg, Color::Cyan);
assert_eq!(buffer[(x, y + 3)].symbol(), "f");
assert_eq!(buffer[(x, y + 3)].fg, Color::Magenta);
assert_eq!(buffer[(x + 6, y + 3)].fg, Color::LightBlue);
assert_eq!(buffer[(x + 21, y + 3)].fg, Color::Yellow);
for _ in 0..3 {
editor.handle(Input::Down);
}
editor.handle(Input::Right);
terminal
.draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, y)].symbol(), "n");
assert_eq!(buffer[(x, y)].fg, Color::Magenta);
assert_eq!(buffer[(x + 20, y)].fg, Color::Yellow);
editor.show_hover("# Replacement".into()).unwrap();
terminal
.draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels))
.unwrap();
assert!(screen_text(&terminal).contains("# Replacement"));
assert!(!screen_text(&terminal).contains("answer"));
}
#[test]
fn hover_scroll_renders_whole_graphemes_in_tiny_workspace() {
let mut editor = Editor::open(None, vec![]).unwrap();