fix: render floating windows and explorer outside workspace panes

This commit is contained in:
2026-10-11 18:18:10 +02:00
parent f595455479
commit 503342bff0
3 changed files with 209 additions and 77 deletions
+2 -2
View File
@@ -40,8 +40,8 @@ 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. 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.
Frontends borrow `completion()` for the read-only item list and selected index: zero is Original text, and other indices select `items[selected - 1]`. The editor owns the immutable original document/carets, atomic candidate transactions, cycling/dismissal, pending Insert undo, and final-only repeat recording. It changes only the primary completion target and translates secondary carets through all edits. The TUI only places a windowed list near the current caret inside the document text workspace, sanitizes/clips labels and detail by grapheme, and preserves gutter, explorer, and hardware cursor ownership; wheel input over the list is consumed without dispatching edits.
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 full editor area 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.
Frontends borrow `completion()` for the read-only item list and selected index: zero is Original text, and other indices select `items[selected - 1]`. The editor owns the immutable original document/carets, atomic candidate transactions, cycling/dismissal, pending Insert undo, and final-only repeat recording. It changes only the primary completion target and translates secondary carets through all edits. The TUI places a windowed list near the current caret, bounded by the full editor rather than its pane, sanitizes/clips labels and detail by grapheme, and preserves hardware cursor ownership; wheel input over the list is consumed without dispatching edits.
Frontends borrow `snippet_selections()` for the active argument's end-exclusive grapheme ranges, including mirrors and empty tabstops. The editor caches those ranges after previews, argument edits, and navigation; rendering adds no snippet allocation. Template edits stay primary-only, translate other carets, and fold resolved replacement text and caret placement into the pending completion repeat step. Per-occurrence ancestry distinguishes nested arguments from adjacent empty stops, allowing enclosing replacements to retire only overwritten descendants.
## Terminal lifecycle and layout
+2
View File
@@ -201,6 +201,8 @@ There is no default close-pane binding. Option must send Alt/Meta in the termina
Pane-focus bindings also work while the ordinary file explorer or picker has focus. Moving to a neighboring pane returns keyboard focus to its document; a floating picker closes, while a docked explorer remains visible. Explicit `tree` or `selection` navigation overrides and disabled keys take precedence over pane-focus bindings.
Floating windows overlay the entire editor, above all workspace panes. File pickers, revision selectors, hover documentation, and completion lists are not clipped to the focused pane; completion remains anchored to its caret. Docked exploration reserves space at the editor edge before the remaining workspace is split into panes. The changed-file explorer follows the same layout, on the editor's right edge.
Diff comparisons reuse the split geometry and directional-focus engine. Option/Alt navigation moves between Before, After, and Files first, then to a neighboring workspace pane when there is no internal target in that direction. Splitting a diff text view creates an ordinary sibling showing the same buffer; historical snapshots remain read-only, including after the comparison closes.
## Buffers
+204 -74
View File
@@ -378,7 +378,19 @@ impl BufferViewports {
} else {
Some(document_layout(area, editor))
};
let workspace = chrome.map_or(area, |areas| areas[0]);
let full_workspace = chrome.map_or(area, |areas| areas[0]);
let (workspace, dock) = match editor.file_picker() {
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
full_workspace,
if editor.diff().is_some() {
DockSide::Right
} else {
editor.file_explorer_side()
},
picker.focused,
),
_ => (full_workspace, None),
};
editor.set_pane_bounds(pane_rect(workspace));
self.rects.clear();
editor
@@ -394,6 +406,7 @@ impl BufferViewports {
continue;
}
self.activate(id, editor);
self.panels.workspace_managed = true;
if multiple {
let block =
Block::default()
@@ -444,6 +457,43 @@ impl BufferViewports {
}
}
self.activate(focused, editor);
self.panels.workspace_managed = false;
if let Some(panel) = dock
&& let Some(picker) = editor.file_picker()
{
draw_file_list(
frame,
picker,
panel,
editor.file_navigation_hint(),
&mut self.panels,
editor
.diff()
.and_then(|diff| diff.comparison.as_ref())
.map(|comparison| comparison.files.as_slice()),
);
if let Some(geometry) = &mut self.panels.diff {
geometry.regions[0] = (panel, Focus::Files);
editor.set_diff_pane_bounds(
geometry
.regions
.map(|(area, focus)| (focus, pane_rect(area))),
);
}
}
if let Some(diff) = editor.diff().filter(|diff| diff.phase != Phase::View) {
draw_diff(
frame,
diff,
area,
editor.selection_navigation_hint(),
editor.file_navigation_hint(),
&mut self.panels,
Some(editor),
);
} else {
draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels);
}
}
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
@@ -901,6 +951,9 @@ fn draw_document(
let focused = editor.render_pane_focused();
if let Some(diff) = editor.diff() {
draw_status(frame, editor, syntax, status_area);
if panels.workspace_managed && diff.phase != Phase::View {
return;
}
draw_diff(
frame,
diff,
@@ -918,7 +971,11 @@ fn draw_document(
}
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
let (viewport, dock) = match editor.file_picker() {
Some(picker) if focused && picker.presentation == FilePresentation::Docked => {
Some(picker)
if !panels.workspace_managed
&& focused
&& picker.presentation == FilePresentation::Docked =>
{
dock_layout(workspace, editor.file_explorer_side(), picker.focused)
}
_ => (workspace, None),
@@ -1294,14 +1351,40 @@ fn draw_document(
if !focused {
return;
}
if let Some(completion) = editor.completion()
&& let Some(caret) = document_caret
panels.caret = document_caret.map(|caret| (viewport, caret));
if !panels.workspace_managed {
if let Some(panel) = dock
&& let Some(picker) = editor.file_picker()
{
panels.completion = draw_completion(frame, completion, viewport, caret);
draw_file_list(
frame,
picker,
panel,
editor.file_navigation_hint(),
panels,
None,
);
}
if let Some(picker) = editor.file_picker() {
if picker.presentation == FilePresentation::Floating {
let panel = floating_panel(overlay_area);
draw_overlays(frame, editor, overlay_area, panels.caret, panels);
}
}
fn draw_overlays(
frame: &mut Frame,
editor: &Editor,
area: Rect,
caret: Option<(Rect, Position)>,
panels: &mut WheelPanels,
) {
if let Some(completion) = editor.completion()
&& let Some((_, caret)) = caret
{
panels.completion = draw_completion(frame, completion, area, caret);
}
if let Some(picker) = editor.file_picker()
&& picker.presentation == FilePresentation::Floating
{
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
draw_file_list(
frame,
@@ -1309,18 +1392,11 @@ fn draw_document(
panel,
editor.file_navigation_hint(),
panels,
None,
editor
.diff()
.and_then(|diff| diff.comparison.as_ref())
.map(|comparison| comparison.files.as_slice()),
);
} else if let Some(panel) = dock {
draw_file_list(
frame,
picker,
panel,
editor.file_navigation_hint(),
panels,
None,
);
}
}
if let Some(palette) = editor.palette() {
draw_palette(
@@ -1331,10 +1407,10 @@ fn draw_document(
);
}
if let Some(hover) = editor.hover() {
draw_hover(frame, hover, viewport, panels);
draw_hover(frame, hover, area, panels);
}
if let Some(diagnostic) = editor.startup_diagnostic() {
draw_startup_diagnostic(frame, diagnostic, overlay_area);
draw_startup_diagnostic(frame, diagnostic, area);
}
}
@@ -1680,11 +1756,6 @@ fn draw_diff_popups(
if !editor.render_pane_focused() {
return;
}
if let Some((text_area, caret)) = caret
&& let Some(completion) = editor.completion()
{
panels.completion = draw_completion(frame, completion, text_area, caret);
}
if area.width > 0 && area.height > 0 {
let footer = Rect::new(area.x, area.bottom() - 1, area.width, 1);
if matches!(editor.mode(), Mode::Command | Mode::Search) {
@@ -1707,19 +1778,9 @@ fn draw_diff_popups(
frame.render_widget(Paragraph::new(display_text(editor.message())), footer);
}
}
if let Some(palette) = editor.palette() {
draw_palette(
frame,
palette,
editor.mode(),
editor.selection_navigation_hint(),
);
}
if let Some(hover) = editor.hover() {
draw_hover(frame, hover, caret.map_or(area, |(area, _)| area), panels);
}
if let Some(diagnostic) = editor.startup_diagnostic() {
draw_startup_diagnostic(frame, diagnostic, area);
panels.caret = caret;
if !panels.workspace_managed {
draw_overlays(frame, editor, area, caret, panels);
}
}
@@ -1740,9 +1801,9 @@ fn draw_diff(
if area.width == 0 || area.height == 0 {
return;
}
frame.render_widget(Clear, area);
if diff.phase != Phase::View {
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
let stage = if diff.phase == Phase::Before {
"Before"
} else {
@@ -1933,6 +1994,7 @@ fn draw_diff(
}
return;
}
frame.render_widget(Clear, area);
let syntax_error = panels
.diff_syntax
@@ -1998,7 +2060,8 @@ fn draw_diff(
notice_area,
);
}
let (panes, navigator) = if diff.files_visible
let (panes, navigator) = if !panels.workspace_managed
&& diff.files_visible
&& diff
.files
.as_ref()
@@ -2254,22 +2317,6 @@ fn draw_diff(
text_widths,
wrapping,
});
if let Some(picker) = &diff.files
&& diff.files_visible
&& picker.presentation == FilePresentation::Floating
&& editor.is_none_or(|editor| editor.render_pane_focused())
{
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
draw_file_list(
frame,
picker,
panel,
file_navigation_hint,
panels,
comparison.map(|comparison| comparison.files.as_slice()),
);
}
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, document_caret, panels);
}
@@ -2862,6 +2909,8 @@ fn diff_visible_rows(
#[derive(Default)]
struct WheelPanels {
workspace_managed: bool,
caret: Option<(Rect, Position)>,
completion: Option<Rect>,
hover: Option<(Rect, usize)>,
hover_syntax: SyntaxCache,
@@ -2903,6 +2952,7 @@ impl WheelPanels {
self.manual_files = None;
}
self.hover = None;
self.caret = None;
self.completion = None;
self.files = None;
self.previous_diff = self.diff.take();
@@ -2959,10 +3009,6 @@ impl WheelPanels {
{
return true;
}
let pane = session.editor().pane_id();
if self.diff.is_some() && self.scroll_comparison(session, mouse, pane) {
return true;
}
let editor = session.editor();
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
return true;
@@ -2994,6 +3040,17 @@ impl WheelPanels {
}
return true;
}
let pane = session.editor().pane_id();
let floating_files = editor.file_picker().is_some_and(|picker| {
picker.presentation == FilePresentation::Floating
&& self
.files
.is_some_and(|(area, _, _)| area.contains(position))
});
if !floating_files && self.diff.is_some() && self.scroll_comparison(session, mouse, pane) {
return true;
}
let editor = session.editor();
if let Some(picker) = editor.file_picker()
&& let Some((panel, height, drawn_top)) = self.files
&& panel.contains(position)
@@ -4823,6 +4880,66 @@ mod tests {
.join("\n")
}
#[test]
fn explorer_dock_reserves_space_outside_all_workspace_panes() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("shared.txt", "first\nsecond")), vec![]).unwrap();
editor.set_project_root(directory.0.clone());
editor.handle(Input::Char('|'));
editor.handle(Input::Char('_'));
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let dock = views.panels.files.unwrap().0;
assert!(views.rects.iter().all(|(_, pane)| pane.right() <= dock.x));
assert_eq!(dock.right(), 120);
assert_eq!(dock.y, 0);
assert_eq!(dock.height, 28);
}
#[test]
fn revision_selector_overlays_and_clears_workspace_pane_borders() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Char('|'));
editor.execute("diff");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Choose Before"));
let results = views.panels.diff.as_ref().unwrap().regions[0].0;
assert!(results.x < 60 && results.right() > 60);
for x in [59, 60] {
assert_eq!(terminal.backend().buffer()[(x, 20)].symbol(), " ");
}
}
#[test]
fn diff_floating_picker_overlays_the_whole_workspace() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.txt", "workspace")), vec![]).unwrap();
editor.handle(Input::Char('|'));
install_readonly_diff(&mut editor, syntax_diff_file("history.txt", "old", "new"));
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(
views.panels.files.unwrap().0,
floating_panel(Rect::new(0, 0, 120, 30))
);
}
#[test]
fn nested_workspace_panes_share_edits_and_undo_but_not_carets() {
let directory = Directory::new();
@@ -6184,14 +6301,10 @@ mod tests {
#[test]
fn hover_scroll_renders_whole_graphemes_in_tiny_workspace() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap();
let mut syntax = SyntaxCache::new();
let (mut top, mut left) = (0, 0);
for _ in 0..2 {
editor.handle(Input::Right);
}
let rendered = terminal
terminal
.draw(|frame| {
draw(
frame,
@@ -6201,16 +6314,33 @@ mod tests {
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
);
})
.unwrap();
let document_cursor = terminal.get_cursor_position().unwrap();
editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap();
for _ in 0..2 {
editor.handle(Input::Right);
}
let mut panels = WheelPanels::default();
let rendered = terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut panels,
)
})
.unwrap();
assert_eq!(rendered.buffer[(2, 0)].symbol(), "e\u{301}");
assert_eq!(rendered.buffer[(3, 0)].symbol(), "👩‍🚀");
assert_eq!(rendered.buffer[(5, 0)].symbol(), "z");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(2, 0)
);
let origin = panels.hover.unwrap().0;
assert_eq!(rendered.buffer[(origin.x, origin.y)].symbol(), "e\u{301}");
assert_eq!(rendered.buffer[(origin.x + 1, origin.y)].symbol(), "👩‍🚀");
assert_eq!(rendered.buffer[(origin.x + 3, origin.y)].symbol(), "z");
assert_eq!(terminal.get_cursor_position().unwrap(), document_cursor);
assert_eq!((top, left), (0, 0));
editor.handle(Input::Down);
editor.handle(Input::Home);
@@ -6227,7 +6357,7 @@ mod tests {
)
})
.unwrap();
assert!(screen_text(&terminal).ends_with("last"));
assert!(screen_text(&terminal).trim().ends_with("last"));
for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();