diff --git a/PLAN.md b/PLAN.md index 91c87718..b9988a8d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -12,12 +12,38 @@ ## 🧭 Current wave (2026-07-23) — TUI → `ShellApp`/`run_with_shell` (vimcode#595) -**Status:** Stage 0 landed this session — `TuiShellApp` scaffold -(`src/tui_main/shell_app.rs`) with `setup`/`tick` fully ported, dormant (not -wired to `main.rs`/`tui_bin.rs`). **This is genuinely multi-session** — the -2026-07-21 note below undersold the coupling depth by roughly an order of -magnitude (see "What this session found" below). Do not re-attempt the -discovery work below; it's done. Pick up at "Staged plan," Stage 1. +**Status:** Stage 0 (`TuiShellApp` scaffold), Stage 1 (#600, paint +centralization), and Stage 2 (#601, `render_content` paints for real) are +landed. Still dormant — not wired to `main.rs`/`tui_bin.rs`. **This is +genuinely multi-session** — the 2026-07-21 note below undersold the coupling +depth by roughly an order of magnitude (see "What this session found" +below). Do not re-attempt the discovery work below; it's done. Pick up at +"Staged plan," Stage 3 (#602). + +**Stage 2 scoping note (2026-07-23):** confirmed structural, not just +unwired — `render_content(&self, backend: &mut dyn Backend, ...)` can +*never* get a raw `ratatui::Frame`/`Buffer`, in any future stage: +`TuiBackend`'s frame pointer (`current_frame_ptr`) is a **private** field +with no public accessor, and `render_content` runs inside quadraui's own +`enter_frame_scope` (`shell_adapter.rs::ShellAdapter::render` → +`tui/run.rs::render_frame`) — so `Backend::draw_*` trait calls work (they +reach the smuggled pointer internally), but nothing needing raw buffer +access ever can, from this signature, period. #601 wires everything that +*is* reachable that way (editor windows, tab/breadcrumb bars, per-window +status lines, the 4 editor-anchored popup kinds) and splits out the true +raw-buffer holdouts as three follow-on issues, all filed and added to the +epic's Work order (`after: 601`) and to #605's dependencies (cutover can't +drop `event_loop()` while these stay unpainted): +[#607](https://github.com/JDonaghy/vimcode/issues/607) sidebar panel +content, [#608](https://github.com/JDonaghy/vimcode/issues/608) +quickfix/bottom panel, [#609](https://github.com/JDonaghy/vimcode/issues/609) +window/group dividers + tab-drag overlay + tab-hover tooltip. Note for +whoever picks up #608: `render_quickfix_panel`, `render_bottom_panel_tabs`, +and `render_terminal_toolbar` (unlike `render_terminal_panel`'s actual PTY +grid content and the debug-output `TextDisplay`'s surrounding chrome) turned +out to already be trait-only, no raw buffer needed — worth checking before +assuming the whole panel needs new plumbing; #601 did not paint them (kept +to its originally-approved scope) but they may be a quick win. **All findings below are also recorded as pinned `coord context` notes on vimcode#595** (ids 260-262) — this section is the human-readable expansion. @@ -119,33 +145,46 @@ depends on one of those two things: Mirrors how GTK's B.5/B.5b actually shipped — many small, independently buildable/testable stages, not one PR: -- **Stage 1 — paint centralization.** Sweep `render_impl.rs` + `panels.rs`: - (a) convert the handful of free-function-on-`frame.buffer_mut()` calls to - their existing `Backend::draw_*` trait equivalents; (b) collapse the ~30 - `enter_frame_scope`/`set_current_theme` call sites to one entry, made by - each of `event_loop()`'s two `terminal.draw(|frame| ...)` closures (keeps - the live path working throughout — Path A style). This alone is valuable - independent of #595 and should be tested via the existing - `cargo test --no-default-features` suite (no behavior change, pure - threading). -- **Stage 2 — `render_content` for real.** Once Stage 1 lands, `draw_frame` - and friends work through `&mut dyn Backend` — wire `render_content` to - call the now-portable paint path against `layout.main_content_bounds`. - Add `driver_with_shell` paint assertions (`screen_contains`). -- **Stage 3 — mouse handling.** Resolve gap 2 (new quadraui trait accessor, +- ✅ **Stage 1 — paint centralization** (#600, landed). Swept + `render_impl.rs` + `panels.rs`: (a) converted the free-function-on- + `frame.buffer_mut()` calls with a `Backend::draw_*` trait equivalent; + (b) collapsed the ~30 `enter_frame_scope`/`set_current_theme` call sites + to one entry per `terminal.draw(|frame| ...)` closure via a new + `with_frame_scope` helper. No behavior change, pure threading. +- ✅ **Stage 2 — `render_content` for real** (#601, landed). Wired + `render_content` to paint the trait-portable subset — editor windows + (`render_all_windows`, `Frame` param now `Option`), tab bars, breadcrumb + bars, per-window status lines, and the completion/hover/editor-hover/ + diff-peek/signature-help popups (extracted into a shared + `paint_editor_popups` so `draw_frame` and `render_content` can't drift) — + via a new `build_screen_for_shell_content` (mirrors `build_screen_for_tui`'s + row-accounting tail without re-subtracting activity-bar/sidebar width, + since `AppShellLayout::main_content_bounds` already excludes that chrome). + `render_tab_bar`/`draw_breadcrumb_bar`/`render_window_status_line`/ + `render_editor_hover_popup` widened from concrete `&mut TuiBackend` to + `&mut dyn quadraui::Backend`, same technique Stage 0 used for + `register_panel_accelerators`. 2 new `driver_with_shell` `screen_contains` + assertions (single-window text, and a vertical-split proving multi-window + painting). What's *not* painted this stage — and structurally can't be, + from `render_content`'s `&mut dyn Backend`-only signature, without raw + `Frame`/`Buffer` access `TuiBackend` doesn't expose — split into three + follow-on issues, see the "Stage 2 scoping note" above: #607 (sidebar + content), #608 (quickfix/bottom panel), #609 (dividers/drag-overlay/ + tab-tooltip). All three now block #605 (cutover) in the epic's Work order. +- **Stage 3 — mouse handling** (#602). Resolve gap 2 (new quadraui trait accessor, or `handle_mouse` rewritten onto `dispatch_mouse_down/drag/up`), then wire `TuiShellApp::handle`'s mouse arms. -- **Stage 4 — key handling.** Wire the remaining `KeyPressed` dispatch +- **Stage 4 — key handling** (#603). Wire the remaining `KeyPressed` dispatch (dialog/palette/completion/context-menu intercepts, `Engine::handle_key`) into `handle()`. -- **Stage 5 — quadraui cursor-placement fix.** Filed as +- **Stage 5 — quadraui cursor-placement fix** (#604). Filed as [quadraui#466](https://github.com/JDonaghy/quadraui/issues/466); wait for it to land before cutover, since without it the live TUI would lose its blinking cursor. -- **Stage 6 — parity + cutover.** Once Stages 1-5 land and `driver_with_shell` - coverage is solid, swap `main.rs`/`tui_bin.rs` to - `quadraui::tui::shell_runner::run_with_shell`, delete `event_loop()`, do - the full manual smoke pass, then land. +- **Stage 6 — parity + cutover** (#605). Once Stages 1-5 *and* 2a/2b/2c + (#607/#608/#609) land and `driver_with_shell` coverage is solid, swap + `main.rs`/`tui_bin.rs` to `quadraui::tui::shell_runner::run_with_shell`, + delete `event_loop()`, do the full manual smoke pass, then land. Not blocked on quadraui#465 (macOS `ShellApp` support) — independent, parallel supply-side item; TUI already runs on macOS via crossterm diff --git a/src/tui_main/panels.rs b/src/tui_main/panels.rs index bfe3cf55..1bf219ff 100644 --- a/src/tui_main/panels.rs +++ b/src/tui_main/panels.rs @@ -790,9 +790,12 @@ pub(super) fn render_panel_hover_popup( /// Render an editor hover popup via the `quadraui::RichTextPopup` /// primitive. Returns `(link_rects, popup_bounds, scrollbar_hit)` for /// mouse hit-testing — derived from the primitive's resolved layout. +/// `backend` is `&mut dyn quadraui::Backend` (not the concrete `TuiBackend`) +/// so this is callable from `TuiShellApp::render_content` (#601) — see +/// `render_impl.rs::render_tab_bar`'s doc comment for the general rationale. #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub(super) fn render_editor_hover_popup( - backend: &mut super::backend::TuiBackend, + backend: &mut dyn quadraui::Backend, eh: &render::EditorHoverPopupData, popup_x: u16, popup_y: u16, @@ -838,8 +841,7 @@ pub(super) fn render_editor_hover_popup( }, ); - use quadraui::Backend; - backend.set_current_theme(super::quadraui_tui::q_theme(theme)); + backend.set_theme(super::quadraui_tui::q_theme(theme)); backend.draw_rich_text_popup(&popup, &layout); let link_rects: Vec<(u16, u16, u16, u16, String)> = layout diff --git a/src/tui_main/render_impl.rs b/src/tui_main/render_impl.rs index 2c651537..cd559cfd 100644 --- a/src/tui_main/render_impl.rs +++ b/src/tui_main/render_impl.rs @@ -98,6 +98,78 @@ pub(super) fn build_screen_for_tui( result } +/// `TuiShellApp::render_content`-side counterpart to [`build_screen_for_tui`] +/// (#601). `render_content` receives `layout.main_content_bounds` from +/// quadraui's `AppShell::render`, which has *already* painted the activity +/// bar + sidebar chrome and carved their width out of `area` — unlike +/// `build_screen_for_tui`, called from the live `event_loop()` path with the +/// *full* terminal rect, this must not subtract activity-bar/sidebar width a +/// second time (that would double-count it and shrink the editor area). +/// +/// Still applies vimcode's own row accounting — quickfix/terminal/ +/// debug-toolbar/wildmenu/status rows — since `AppShellLayout` has no +/// concept of any of those; this mirrors `build_screen_for_tui`'s tail (from +/// its `content_bounds` computation onward) so the two paths share the same +/// formula and can't silently drift. `area` is treated directly as the +/// editor column. +/// +/// The menu-bar row is still reserved (so window rects land where the live +/// path would put them) but not painted — `render_content` doesn't paint a +/// menu bar this stage (out of scope; TUI's menu bar defaults hidden except +/// in vscode-mode or via Alt-reveal, tracked with the rest of key dispatch +/// in #603). +pub(super) fn build_screen_for_shell_content( + engine: &Engine, + theme: &Theme, + area: Rect, +) -> render::ScreenLayout { + let qf_height: u16 = if engine.quickfix_open { 6 } else { 0 }; + let bottom_panel_open = engine.terminal_open || engine.bottom_panel_open; + let term_height: u16 = if bottom_panel_open { + let target = super::terminal_target_maximize_rows_tui(engine, area.height); + engine.effective_terminal_panel_rows(target) + 2 + } else { + 0 + }; + let menu_height: u16 = if engine.menu_bar_visible { 1 } else { 0 }; + let dbg_height: u16 = if engine.debug_toolbar_visible { 1 } else { 0 }; + let wildmenu_height: u16 = if !engine.wildmenu_items.is_empty() { + 1 + } else { + 0 + }; + let per_window_status = engine.settings.window_status_line; + let global_status_rows: u16 = if per_window_status { 0 } else { 1 }; + let separate_status = + per_window_status && !engine.settings.status_line_above_terminal && bottom_panel_open; + let separated_status_rows: u16 = if separate_status { 1 } else { 0 }; + let content_rows = area.height.saturating_sub( + 1 + global_status_rows + + qf_height + + term_height + + menu_height + + dbg_height + + wildmenu_height + + separated_status_rows, + ); + let editor_origin_x = area.x as f64; + let editor_origin_y = area.y as f64 + menu_height as f64; + let content_bounds = WindowRect::new( + editor_origin_x, + editor_origin_y, + area.width as f64, + content_rows as f64, + ); + let tui_tab_bar_height = if engine.settings.breadcrumbs && !engine.terminal_maximized { + 2.0 + } else { + 1.0 + }; + let (window_rects, _dividers) = + engine.calculate_group_window_rects(content_bounds, tui_tab_bar_height); + build_screen_layout(engine, theme, &window_rects, 1.0, 1.0, true) +} + // ─── Frame rendering ────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] @@ -361,7 +433,7 @@ pub(super) fn draw_frame( } // Render windows first so tab bars draw on top (prevents window content // from overwriting an adjacent group's tab bar in horizontal splits). - render_all_windows(backend, frame, &screen.windows, theme); + render_all_windows(backend, Some(frame), &screen.windows, theme); // Draw each group's tab bar. Tab bar sits tab_bar_height rows above // the group's window content (bounds.y - tab_bar_height). for target in &tab_bar_targets { @@ -453,7 +525,7 @@ pub(super) fn draw_frame( let layout = draw_breadcrumb_bar(backend, bc_rect, t.bar, theme); *t.draw_layout.borrow_mut() = Some(layout); } - render_all_windows(backend, frame, &screen.windows, theme); + render_all_windows(backend, Some(frame), &screen.windows, theme); } // Register the editor viewport as a scroll surface so dispatch_scroll @@ -503,161 +575,21 @@ pub(super) fn draw_frame( } } - // ── Completion popup (rendered on top of editor) ─────────────────────── - if let Some(ref menu) = screen.completion { - if let Some(active_win) = screen - .windows - .iter() - .find(|w| w.window_id == screen.active_window_id) - { - if let Some((cursor_pos, _)) = &active_win.cursor { - let gutter_w = active_win.gutter_char_width as u16; - let win_x = active_win.rect.x as u16; - let win_y = active_win.rect.y as u16; - let raw = active_win - .lines - .get(cursor_pos.view_line) - .map(|l| l.raw_text.as_str()) - .unwrap_or(""); - let vis_col = char_col_to_visual(raw, cursor_pos.col, active_win.tabstop) - .saturating_sub(active_win.scroll_left) as u16; - let popup_x = win_x + gutter_w + vis_col; - let popup_y = win_y + cursor_pos.view_line as u16 + 1; - // Per D6: build quadraui::Completions + layout + rasterise. - let completions = render::completion_menu_to_quadraui_completions(menu); - let area = frame.area(); - let viewport = quadraui::Rect::new( - area.x as f32, - area.y as f32, - area.width as f32, - area.height as f32, - ); - let popup_width = (menu.max_width as f32 + 4.0).max(12.0); - let max_popup_height = 10.0; - let layout = completions.layout( - popup_x as f32, - popup_y as f32 - 1.0, // cursor y; layout adds line_height below - 1.0, - viewport, - popup_width, - max_popup_height, - |_| quadraui::CompletionItemMeasure::new(1.0), - ); - backend.draw_completions(&completions, &layout); - *completion_layout_out = Some(layout); - } - } - } - - // ── Hover popup (rendered on top of editor) ────────────────────────────── - if let Some(ref hover) = screen.hover { - if let Some(active_win) = screen - .windows - .iter() - .find(|w| w.window_id == screen.active_window_id) - { - let gutter_w = active_win.gutter_char_width as u16; - let win_x = active_win.rect.x as u16; - let win_y = active_win.rect.y as u16; - let anchor_view = hover.anchor_line.saturating_sub(active_win.scroll_top) as u16; - let vis_col = hover.anchor_col.saturating_sub(active_win.scroll_left) as u16; - let popup_x = win_x + gutter_w + vis_col; - let popup_y = win_y + anchor_view; - // Per D6: build quadraui::Tooltip + layout + rasterise. - let area = frame.area(); - let viewport = quadraui::Rect::new( - area.x as f32, - area.y as f32, - area.width as f32, - area.height as f32, - ); - let (tooltip, layout) = - render::hover_popup_to_quadraui_tooltip(hover, popup_x, popup_y, viewport); - backend.draw_tooltip(&tooltip, &layout); - } - } - - // ── Editor hover popup (rich markdown, triggered by gh or mouse dwell) ─ - *editor_hover_popup_rect_out = None; // Clear stale rect before rendering - *editor_hover_scrollbar_out = None; - if let Some(ref eh) = screen.editor_hover { - if let Some(active_win) = screen - .windows - .iter() - .find(|w| w.window_id == screen.active_window_id) - { - let gutter_w = active_win.gutter_char_width as u16; - let win_x = active_win.rect.x as u16; - let win_y = active_win.rect.y as u16; - // Use frozen scroll offsets so the popup stays fixed on screen - let anchor_view = eh.anchor_line.saturating_sub(eh.frozen_scroll_top) as u16; - let vis_col = eh.anchor_col.saturating_sub(eh.frozen_scroll_left) as u16; - let popup_x = win_x + gutter_w + vis_col; - let popup_y = win_y + anchor_view; - let (eh_links, eh_rect, eh_sb) = - render_editor_hover_popup(backend, eh, popup_x, popup_y, area, theme); - *editor_hover_link_rects_out = eh_links; - *editor_hover_popup_rect_out = eh_rect; - *editor_hover_scrollbar_out = eh_sb; - } - } - - // ── Diff peek popup (inline git hunk preview) ────────────────────────── - if let Some(ref peek) = screen.diff_peek { - if let Some(active_win) = screen - .windows - .iter() - .find(|w| w.window_id == screen.active_window_id) - { - let gutter_w = active_win.gutter_char_width as u16; - let win_x = active_win.rect.x as u16; - let win_y = active_win.rect.y as u16; - let anchor_view = peek.anchor_line.saturating_sub(active_win.scroll_top) as u16; - let popup_x = win_x + gutter_w; - // anchor at the cursor's own row; placement=Bottom (with - // primitive fallback to Top) puts the popup just below it. - let popup_y = win_y + anchor_view; - // Per D6: build quadraui::Tooltip + layout + rasterise. - let area = frame.area(); - let viewport = quadraui::Rect::new( - area.x as f32, - area.y as f32, - area.width as f32, - area.height as f32, - ); - let (tooltip, layout) = - render::diff_peek_to_quadraui_tooltip(peek, popup_x, popup_y, viewport, theme); - backend.draw_tooltip(&tooltip, &layout); - } - } - - // ── Signature-help popup (shown in insert mode when cursor is inside a call) ─ - if let Some(ref sig) = screen.signature_help { - if let Some(active_win) = screen - .windows - .iter() - .find(|w| w.window_id == screen.active_window_id) - { - let gutter_w = active_win.gutter_char_width as u16; - let win_x = active_win.rect.x as u16; - let win_y = active_win.rect.y as u16; - let anchor_view = sig.anchor_line.saturating_sub(active_win.scroll_top) as u16; - let vis_col = sig.anchor_col.saturating_sub(active_win.scroll_left) as u16; - let popup_x = win_x + gutter_w + vis_col; - let popup_y = win_y + anchor_view; - // Per D6: build quadraui::Tooltip + layout + rasterise. - let area = frame.area(); - let viewport = quadraui::Rect::new( - area.x as f32, - area.y as f32, - area.width as f32, - area.height as f32, - ); - let (tooltip, layout) = - render::signature_help_to_quadraui_tooltip(sig, popup_x, popup_y, viewport, theme); - backend.draw_tooltip(&tooltip, &layout); - } - } + // ── Editor popups: completion / hover / editor-hover / diff-peek / + // signature-help — extracted to `paint_editor_popups` (#601) so + // `TuiShellApp::render_content` can call the exact same code (these are + // all already trait-only, no raw `Frame`/`Buffer` access, so nothing + // here needed to change to become reachable from `&mut dyn Backend`). + paint_editor_popups( + backend, + screen, + area, + theme, + completion_layout_out, + editor_hover_link_rects_out, + editor_hover_popup_rect_out, + editor_hover_scrollbar_out, + ); // ── Quickfix panel (persistent bottom strip) ────────────────────────────── if let Some(ref qf) = screen.quickfix { @@ -1030,6 +962,165 @@ pub(super) fn draw_frame( } } +/// Paint the editor-anchored popups: completion menu, LSP hover, the rich +/// "editor hover" markdown popup, diff-peek, and signature-help. +/// +/// Extracted out of `draw_frame` (#601) because every one of these is +/// already trait-only — `backend.draw_completions`/`draw_tooltip` plus +/// `render_editor_hover_popup` (also widened to `&mut dyn Backend` in +/// #601) — so the exact same code is callable from +/// `TuiShellApp::render_content`, which never has a raw `ratatui::Frame` +/// to pass. `draw_frame` now calls this too, so the two paint paths can't +/// drift on this logic. `area` stands in for each block's original +/// `frame.area()` call (all four computed the identical value from the +/// same frame, just redundantly per-block). +#[allow(clippy::too_many_arguments)] +pub(super) fn paint_editor_popups( + backend: &mut dyn quadraui::Backend, + screen: &render::ScreenLayout, + area: Rect, + theme: &Theme, + completion_layout_out: &mut Option, + editor_hover_link_rects_out: &mut Vec<(u16, u16, u16, u16, String)>, + editor_hover_popup_rect_out: &mut Option<(u16, u16, u16, u16)>, + editor_hover_scrollbar_out: &mut Option, +) { + let viewport = quadraui::Rect::new( + area.x as f32, + area.y as f32, + area.width as f32, + area.height as f32, + ); + + // ── Completion popup (rendered on top of editor) ─────────────────────── + if let Some(ref menu) = screen.completion { + if let Some(active_win) = screen + .windows + .iter() + .find(|w| w.window_id == screen.active_window_id) + { + if let Some((cursor_pos, _)) = &active_win.cursor { + let gutter_w = active_win.gutter_char_width as u16; + let win_x = active_win.rect.x as u16; + let win_y = active_win.rect.y as u16; + let raw = active_win + .lines + .get(cursor_pos.view_line) + .map(|l| l.raw_text.as_str()) + .unwrap_or(""); + let vis_col = char_col_to_visual(raw, cursor_pos.col, active_win.tabstop) + .saturating_sub(active_win.scroll_left) as u16; + let popup_x = win_x + gutter_w + vis_col; + let popup_y = win_y + cursor_pos.view_line as u16 + 1; + // Per D6: build quadraui::Completions + layout + rasterise. + let completions = render::completion_menu_to_quadraui_completions(menu); + let popup_width = (menu.max_width as f32 + 4.0).max(12.0); + let max_popup_height = 10.0; + let layout = completions.layout( + popup_x as f32, + popup_y as f32 - 1.0, // cursor y; layout adds line_height below + 1.0, + viewport, + popup_width, + max_popup_height, + |_| quadraui::CompletionItemMeasure::new(1.0), + ); + backend.draw_completions(&completions, &layout); + *completion_layout_out = Some(layout); + } + } + } + + // ── Hover popup (rendered on top of editor) ────────────────────────────── + if let Some(ref hover) = screen.hover { + if let Some(active_win) = screen + .windows + .iter() + .find(|w| w.window_id == screen.active_window_id) + { + let gutter_w = active_win.gutter_char_width as u16; + let win_x = active_win.rect.x as u16; + let win_y = active_win.rect.y as u16; + let anchor_view = hover.anchor_line.saturating_sub(active_win.scroll_top) as u16; + let vis_col = hover.anchor_col.saturating_sub(active_win.scroll_left) as u16; + let popup_x = win_x + gutter_w + vis_col; + let popup_y = win_y + anchor_view; + // Per D6: build quadraui::Tooltip + layout + rasterise. + let (tooltip, layout) = + render::hover_popup_to_quadraui_tooltip(hover, popup_x, popup_y, viewport); + backend.draw_tooltip(&tooltip, &layout); + } + } + + // ── Editor hover popup (rich markdown, triggered by gh or mouse dwell) ─ + *editor_hover_popup_rect_out = None; // Clear stale rect before rendering + *editor_hover_scrollbar_out = None; + if let Some(ref eh) = screen.editor_hover { + if let Some(active_win) = screen + .windows + .iter() + .find(|w| w.window_id == screen.active_window_id) + { + let gutter_w = active_win.gutter_char_width as u16; + let win_x = active_win.rect.x as u16; + let win_y = active_win.rect.y as u16; + // Use frozen scroll offsets so the popup stays fixed on screen + let anchor_view = eh.anchor_line.saturating_sub(eh.frozen_scroll_top) as u16; + let vis_col = eh.anchor_col.saturating_sub(eh.frozen_scroll_left) as u16; + let popup_x = win_x + gutter_w + vis_col; + let popup_y = win_y + anchor_view; + let (eh_links, eh_rect, eh_sb) = + render_editor_hover_popup(backend, eh, popup_x, popup_y, area, theme); + *editor_hover_link_rects_out = eh_links; + *editor_hover_popup_rect_out = eh_rect; + *editor_hover_scrollbar_out = eh_sb; + } + } + + // ── Diff peek popup (inline git hunk preview) ────────────────────────── + if let Some(ref peek) = screen.diff_peek { + if let Some(active_win) = screen + .windows + .iter() + .find(|w| w.window_id == screen.active_window_id) + { + let gutter_w = active_win.gutter_char_width as u16; + let win_x = active_win.rect.x as u16; + let win_y = active_win.rect.y as u16; + let anchor_view = peek.anchor_line.saturating_sub(active_win.scroll_top) as u16; + let popup_x = win_x + gutter_w; + // anchor at the cursor's own row; placement=Bottom (with + // primitive fallback to Top) puts the popup just below it. + let popup_y = win_y + anchor_view; + // Per D6: build quadraui::Tooltip + layout + rasterise. + let (tooltip, layout) = + render::diff_peek_to_quadraui_tooltip(peek, popup_x, popup_y, viewport, theme); + backend.draw_tooltip(&tooltip, &layout); + } + } + + // ── Signature-help popup (shown in insert mode when cursor is inside a call) ─ + if let Some(ref sig) = screen.signature_help { + if let Some(active_win) = screen + .windows + .iter() + .find(|w| w.window_id == screen.active_window_id) + { + let gutter_w = active_win.gutter_char_width as u16; + let win_x = active_win.rect.x as u16; + let win_y = active_win.rect.y as u16; + let anchor_view = sig.anchor_line.saturating_sub(active_win.scroll_top) as u16; + let vis_col = sig.anchor_col.saturating_sub(active_win.scroll_left) as u16; + let popup_x = win_x + gutter_w + vis_col; + let popup_y = win_y + anchor_view; + // Per D6: build quadraui::Tooltip + layout + rasterise. + let (tooltip, layout) = + render::signature_help_to_quadraui_tooltip(sig, popup_x, popup_y, viewport, theme); + backend.draw_tooltip(&tooltip, &layout); + } + } +} + /// Convert a TUI-local `FolderPickerState` into a `quadraui::Palette`. /// /// FolderPickerState lives in the TUI module (it's not portable across @@ -1376,20 +1467,24 @@ pub(super) fn compute_tui_tab_drop_zone( /// /// The pre-built `quadraui::TabBar` primitive comes from `ScreenLayout` /// (built by `render::build_screen_layout`). +/// `backend` is `&mut dyn quadraui::Backend` (not the concrete `TuiBackend`) +/// so this is callable from `TuiShellApp::render_content` (#601), which only +/// ever gets a trait object — never a raw `ratatui::Frame`. Existing callers +/// passing a concrete `&mut TuiBackend` still work unchanged via Rust's +/// implicit unsized coercion at the call site. pub(super) fn render_tab_bar( - backend: &mut super::backend::TuiBackend, + backend: &mut dyn quadraui::Backend, area: Rect, bar: &quadraui::TabBar, theme: &Theme, ) -> usize { - use quadraui::Backend; let q_rect = quadraui::Rect::new( area.x as f32, area.y as f32, area.width as f32, area.height as f32, ); - backend.set_current_theme(super::quadraui_tui::q_theme(theme)); + backend.set_theme(super::quadraui_tui::q_theme(theme)); let hits = backend.draw_tab_bar(q_rect, bar, None); hits.available_cols } @@ -1399,28 +1494,34 @@ pub(super) fn render_tab_bar( /// The pre-built `quadraui::StatusBar` primitive comes from /// `ScreenLayout` (built by `render::build_screen_layout`). /// Returns the `StatusBarLayout` for click-time hit testing. +/// See `render_tab_bar`'s doc comment for why `backend` is the trait object +/// rather than the concrete `TuiBackend` (#601). pub(super) fn draw_breadcrumb_bar( - backend: &mut super::backend::TuiBackend, + backend: &mut dyn quadraui::Backend, area: Rect, bar: &quadraui::StatusBar, theme: &Theme, ) -> quadraui::StatusBarLayout { - use quadraui::Backend; let q_rect = quadraui::Rect::new( area.x as f32, area.y as f32, area.width as f32, area.height as f32, ); - backend.set_current_theme(super::quadraui_tui::q_theme(theme)); + backend.set_theme(super::quadraui_tui::q_theme(theme)); backend.draw_status_bar(q_rect, bar, None, None) } // ─── Editor windows ─────────────────────────────────────────────────────────── +/// `frame: None` (from `TuiShellApp::render_content`, #601) skips cursor +/// placement (see `render_window`'s doc comment) *and* skips +/// `render_separators`' window-divider lines — raw `Buffer` writes with no +/// `Backend::draw_*` trait equivalent yet, tracked as vimcode#609. The live +/// `draw_frame` path keeps passing `Some(frame)`, unchanged behavior. pub(super) fn render_all_windows( - backend: &mut super::backend::TuiBackend, - frame: &mut ratatui::Frame, + backend: &mut dyn quadraui::Backend, + mut frame: Option<&mut ratatui::Frame>, windows: &[RenderedWindow], theme: &Theme, ) { @@ -1432,9 +1533,11 @@ pub(super) fn render_all_windows( width: window.rect.width as u16, height: window.rect.height as u16, }; - render_window(backend, frame, win_rect, window, theme); + render_window(backend, frame.as_deref_mut(), win_rect, window, theme); + } + if let Some(frame) = frame { + render_separators(frame.buffer_mut(), windows, theme); } - render_separators(frame.buffer_mut(), windows, theme); } /// Render the unified picker popup. Supports single-pane (no preview) and @@ -1471,9 +1574,16 @@ pub(super) fn render_picker_popup( /// applying the rasteriser's returned cursor position when the shape /// is `Bar` / `Underline` (which sets `Frame`-level cursor state and /// can't live inside a `Buffer`-only rasteriser). +/// +/// `frame` is `Option` (#601): `TuiShellApp::render_content` only ever gets +/// `&mut dyn quadraui::Backend`, never a raw `ratatui::Frame` (confirmed — +/// `TuiBackend`'s frame pointer is private with no public accessor), so it +/// calls this with `None` and simply doesn't get cursor placement — the +/// same already-tracked gap as quadraui#466 (vimcode#604). The live +/// `draw_frame` path keeps passing `Some(frame)`, unchanged behavior. pub(super) fn render_window( - backend: &mut super::backend::TuiBackend, - frame: &mut ratatui::Frame, + backend: &mut dyn quadraui::Backend, + frame: Option<&mut ratatui::Frame>, area: Rect, window: &RenderedWindow, theme: &Theme, @@ -1500,13 +1610,10 @@ pub(super) fn render_window( editor_area.width as f32, editor_area.height as f32, ); - backend.set_current_theme(super::quadraui_tui::q_theme(theme)); - let result = { - use quadraui::Backend; - backend.draw_editor(editor_q_rect, &editor) - }; + backend.set_theme(super::quadraui_tui::q_theme(theme)); + let result = backend.draw_editor(editor_q_rect, &editor); - if let Some(pos) = result.cursor_position { + if let (Some(frame), Some(pos)) = (frame, result.cursor_position) { frame.set_cursor_position(pos); } @@ -1527,21 +1634,22 @@ pub(super) fn render_window( /// after the layout's hit_test() resolves a click — TUI doesn't /// consume the hit regions returned by `draw_status_bar` because the /// click handler runs the layout on demand against current bar width. +/// See `render_tab_bar`'s doc comment for why `backend` is the trait object +/// rather than the concrete `TuiBackend` (#601). fn render_window_status_line( - backend: &mut super::backend::TuiBackend, + backend: &mut dyn quadraui::Backend, x: u16, y: u16, width: u16, status: &crate::render::WindowStatusLine, theme: &crate::render::Theme, ) { - use quadraui::Backend; let bar = crate::render::window_status_line_to_status_bar( status, quadraui::WidgetId::new("status:window"), ); let q_rect = quadraui::Rect::new(x as f32, y as f32, width as f32, 1.0); - backend.set_current_theme(super::quadraui_tui::q_theme(theme)); + backend.set_theme(super::quadraui_tui::q_theme(theme)); let _ = backend.draw_status_bar(q_rect, &bar, None, None); } diff --git a/src/tui_main/shell_app.rs b/src/tui_main/shell_app.rs index 145e9dc1..582a631c 100644 --- a/src/tui_main/shell_app.rs +++ b/src/tui_main/shell_app.rs @@ -21,26 +21,39 @@ //! //! # What's intentionally NOT yet ported, and why //! -//! Three structural gaps were found while scoping this stage (recorded as -//! pinned `coord context` notes on vimcode#595 for the next session): +//! Three structural gaps were found while scoping Stage 0 (recorded as +//! pinned `coord context` notes on vimcode#595): //! //! 1. **Painting.** `render_content(&self, backend: &mut dyn Backend, ...)` -//! never gets a raw `ratatui::Frame` — but `render_impl.rs` + `panels.rs` -//! (~4,000 lines combined) call `backend.enter_frame_scope(frame, ...)` -//! at ~30 sites, and several sites (editor, toast stack, drop overlay, -//! tooltip, dialog, context menu, find/replace) call quadraui's *free* -//! rasteriser functions directly on `frame.buffer_mut()` instead of the -//! equivalent `Backend::draw_*` trait method that already exists and -//! would work through `&mut dyn Backend`. Fixing this is a real but -//! bounded sweep (same underlying function either way — swap the call -//! site, not the logic) and is the next stage's main body of work. +//! never gets a raw `ratatui::Frame` — confirmed structural, not just +//! unwired: quadraui's `TuiBackend` stashes its frame pointer in a +//! *private* field (`current_frame_ptr`) with no public accessor, and +//! `render_content` runs inside quadraui's own `enter_frame_scope` (see +//! `shell_adapter.rs::ShellAdapter::render` / +//! `tui/run.rs::render_frame`) — so `Backend::draw_*` trait calls work +//! fine (they use the smuggled pointer internally), but nothing needing +//! raw `Frame`/`Buffer` access can ever work from this signature, full +//! stop. #600 (Stage 1) converted the ~13 sites that were free-function +//! calls with a trait equivalent; #601 (Stage 2) wires the +//! now-trait-only subset — editor windows, tab bars, breadcrumb bars, +//! per-window status lines, and the completion/hover/editor-hover/ +//! diff-peek/signature-help popups — into `render_content`, via +//! `render_impl.rs::build_screen_for_shell_content` + +//! `paint_editor_popups`. What #601 still cannot paint (true raw-buffer +//! holdouts, each filed as its own follow-on, all blocking #605 +//! cutover): sidebar panel content (#607), quickfix panel + bottom +//! panel/terminal PTY content (#608), window/group divider lines + +//! tab-drag overlay + tab-hover tooltip (#609), and cursor placement +//! (#604 / quadraui#466). The menu bar row is reserved in the layout +//! math but not painted either (out of scope for #601; folds into key +//! dispatch, #603). //! 2. **Mouse handling.** `mouse::handle_mouse` (~3,066 lines) takes //! `&mut quadraui::DragState` + `&mut quadraui::ModalStack` directly via //! `TuiBackend::drag_and_modal_mut()` — a concrete-only method the //! `Backend` trait deliberately doesn't expose. It cannot be called from //! `handle(&mut self, event, backend: &mut dyn Backend, ...)` as-is. -//! Needs either a trait-level accessor or a rewrite onto -//! `quadraui::dispatch_mouse_down/drag/up`. +//! Needs either a trait-level accessor (quadraui#467) or a rewrite onto +//! `quadraui::dispatch_mouse_down/drag/up`. Tracked as #602. //! 3. **Editor cursor placement.** `Backend::draw_editor`'s //! `EditorPaintResult::cursor_position` is documented "host applies via //! `Frame::set_cursor_position`", but no consumer of it exists anywhere @@ -48,15 +61,14 @@ //! Frame to call it on. Filed as quadraui#466: cache the position on //! `TuiBackend`, apply it in `tui/run.rs::render_frame` the same way //! `apply_selection_highlight` already runs post-`render_content`. +//! Tracked as #604. //! //! Given (1) and (2), `handle()` below only implements the two dispatch //! layers that genuinely don't need raw Frame/DragState access — panel-key //! accelerators and the `MenuSystem` intercept — plus routes plain //! `KeyPressed` events (no mouse) to `Engine::handle_key`, which is already //! backend-agnostic. Everything else returns `Reaction::Continue` with a -//! `// TODO(#595)` marker rather than a half-correct guess. `render_content` -//! computes the screen layout (pure, no Frame needed) but does not yet -//! paint, pending gap (1). +//! `// TODO(#595)` marker rather than a half-correct guess. //! //! Also NOT yet ported: the "#318" Alt+menu-letter "reveal menu bar" shim //! sitting between those two dispatch layers in `event_loop()` @@ -253,13 +265,89 @@ impl ShellApp for TuiShellApp { fn render_content( &self, - _backend: &mut dyn quadraui::Backend, - _layout: &quadraui::AppShellLayout, + backend: &mut dyn quadraui::Backend, + layout: &quadraui::AppShellLayout, ) { - // Screen-layout computation is pure (no Frame needed) and safe to - // run today; left here so the next stage's paint sweep has a - // concrete anchor. Painting itself is gap (1) in the module doc. - let _theme = self.theme(); + // #601: paints the trait-portable subset of `draw_frame` — editor + // windows, tab bars, breadcrumb bars, per-window status lines, and + // the editor-anchored popups — into `layout.main_content_bounds`. + // See the module doc's gap (1) for exactly what's still deferred + // (sidebar content #607, quickfix/bottom panel #608, dividers/ + // drag-overlay/tab-tooltip #609, cursor placement #604) and why. + let theme = self.theme(); + let main = layout.main_content_bounds; + if main.width < 1.0 || main.height < 1.0 { + return; + } + let area = Rect { + x: main.x.round() as u16, + y: main.y.round() as u16, + width: main.width.round() as u16, + height: main.height.round() as u16, + }; + + backend.set_theme(super::quadraui_tui::q_theme(&theme)); + + let screen = build_screen_for_shell_content(&self.engine, &theme, area); + + // ── Tab bar(s) + breadcrumb bar(s) + editor windows ───────────── + // Windows are painted first (matches `draw_frame`'s split-group + // order — see its own comment) so window content can't overwrite + // an adjacent group's tab bar; divider lines between windows are + // skipped here (#609), same as passing `frame: None` skips them in + // `render_all_windows`. + render_all_windows(backend, None, &screen.windows, &theme); + + let tui_tbh: f64 = if self.engine.settings.breadcrumbs && !self.engine.terminal_maximized { + 2.0 + } else { + 1.0 + }; + let tab_bar_targets = render::tab_bar_draw_targets( + &self.engine, + &screen, + 1.0, + tui_tbh, + (area.x as f64, area.y as f64, area.width as f64), + ); + for target in &tab_bar_targets { + let g_tab = Rect { + x: target.rect.x as u16, + y: target.rect.y as u16, + width: target.rect.width as u16, + height: 1, + }; + render_tab_bar(backend, g_tab, target.bar, &theme); + } + for t in render::breadcrumb_draw_targets(&screen, self.engine.terminal_maximized, 1.0) { + let bc_rect = Rect { + x: t.rect.x as u16, + y: t.rect.y as u16, + width: t.rect.width as u16, + height: 1, + }; + let bc_layout = draw_breadcrumb_bar(backend, bc_rect, t.bar, &theme); + *t.draw_layout.borrow_mut() = Some(bc_layout); + } + + // ── Editor-anchored popups (completion/hover/editor-hover/ + // diff-peek/signature-help) — same code `draw_frame` calls, all + // already trait-only (#601's `paint_editor_popups` extraction). + let mut completion_layout = self.completion_layout.borrow_mut(); + let mut editor_hover_link_rects = self.editor_hover_link_rects.borrow_mut(); + let mut editor_hover_popup_rect = self.editor_hover_popup_rect.get(); + let mut editor_hover_scrollbar = self.editor_hover_scrollbar.borrow_mut(); + paint_editor_popups( + backend, + &screen, + area, + &theme, + &mut completion_layout, + &mut editor_hover_link_rects, + &mut editor_hover_popup_rect, + &mut editor_hover_scrollbar, + ); + self.editor_hover_popup_rect.set(editor_hover_popup_rect); } fn handle( @@ -679,17 +767,58 @@ mod tests { /// End-to-end smoke: the `ShellConfig`/`PanelDefinition` wiring this /// stage introduced constructs through the real `driver_with_shell` - /// harness and paints a first frame without panicking. `render_content` - /// is still a stub (gap 1 in the module doc), so this only proves the - /// plumbing, not painted content. + /// harness and paints a first frame without panicking. #[test] fn shell_app_constructs_via_driver_with_shell() { let driver = driver_with_shell(TuiShellApp::new(None), config(), 80, 24); - // AppShell chrome (activity bar) paints even though render_content - // doesn't yet — proves the shell/adapter wiring is sound. let _ = driver.screen(); } + /// #601: `render_content` must actually paint the active editor + /// window's text through the `ShellApp` path — this is the core claim + /// of the stage, so assert on it directly rather than just "didn't + /// panic". `driver_with_shell` (via `TuiDriver::new`) runs `setup` + + /// one `render()` pass immediately, so inserting the marker text into + /// the engine *before* constructing the driver is enough for it to + /// show up in the first painted frame. + #[test] + fn render_content_paints_editor_text_via_shell_app() { + let mut app = TuiShellApp::new(None); + app.engine + .buffer_mut() + .insert(0, "ZQXW_STAGE2_EDITOR_MARKER"); + let driver = driver_with_shell(app, config(), 80, 24); + let screen = driver.screen(); + assert!( + screen.contains("ZQXW_STAGE2_EDITOR_MARKER"), + "editor content should paint via TuiShellApp::render_content; screen:\n{screen}" + ); + } + + /// #601: `render_content` must also paint per-editor-group tab bars — + /// exercise the multi-window code path (`render_all_windows` + + /// `render::tab_bar_draw_targets` for `screen.editor_group_split`) by + /// opening a vertical split before painting. Vim/vimcode splits show + /// the *same* buffer in both panes, so the marker text (on line 0, + /// visible from the top in a freshly split window) should appear once + /// per pane — proving both windows actually painted, not just the + /// first. + #[test] + fn render_content_paints_multiple_windows_via_shell_app() { + let mut app = TuiShellApp::new(None); + app.engine + .buffer_mut() + .insert(0, "ZQXW_STAGE2_SPLIT_MARKER"); + app.engine.open_editor_group(SplitDirection::Vertical); + let driver = driver_with_shell(app, config(), 120, 24); + let screen = driver.screen(); + let occurrences = screen.matches("ZQXW_STAGE2_SPLIT_MARKER").count(); + assert_eq!( + occurrences, 2, + "expected the marker text to paint once per split pane; screen:\n{screen}" + ); + } + /// `dispatch_panel_accelerator_sizeless`'s `ACC_TERMINAL_TOGGLE_MAX` arm /// must derive `terminal_max_rows` from `screen_h` (the terminal's row /// count), not `screen_w` — the bug review iteration 1 of vimcode#595