diff --git a/GOALS.md b/GOALS.md index e41badd6..af1632bb 100644 --- a/GOALS.md +++ b/GOALS.md @@ -6,7 +6,7 @@ > line. The `Platform-Neutrality Rule` at the top of `CLAUDE.md` is the *operational > rule*; **this file is the source of truth for *intent* and *sequencing*.** > -> _Last updated: 2026-06-26_ +> _Last updated: 2026-07-21_ ## ๐ŸŽฏ North star @@ -76,14 +76,37 @@ deletions waiting to be picked up: |---|---|---| | **#515** | editor-group drag-and-drop โ†’ `TabGroupController` | quadraui#349 LANDED, but **quadraui#375** (TUI drag-start panics) must be fixed before adoption is safe | -**Blocked on quadraui** โ€” genuinely waiting on unbuilt infra (the *only* one left): +**Blocked on quadraui** โ€” genuinely waiting on unbuilt infra: none currently. **#480 +(the last entry here) shipped 2026-07-21** โ€” quadraui#223/#224/#225 all closed, TUI +*and* GTK migrated to `TextInput`/dual-mode `Palette`/`Dialog`+`DialogTable` (GTK's +git-sidebar chrome had been silently unpainted under `ShellApp` since #493 โ€” fixed +in the same pass). PR open, pending merge. When a new #7 issue is discovered to be +supply-blocked, add its row back here. -| Issue | Migration | Blocked on (quadraui milestone #9) | -|---|---|---| -| **#480** | TUI Source Control panel โ†’ shared primitives | **#223** ButtonBar ยท **#224** dual-mode Palette ยท **#225** Dialog rich content (TextInput #222 already landed) | +## Architecture milestones โ€” beyond primitive-by-primitive adoption -## Status (2026-06-26) +Two larger, multi-session items surfaced 2026-07-21 while scoping #480's follow-on +work. Both are sequencing/architecture questions, not single-primitive swaps, so +they're tracked here rather than in the per-issue tables above: +| Issue | What | Status | +|---|---|---| +| **vimcode#595** | TUI โ†’ `quadraui::ShellApp` + `run_with_shell`, mirroring GTK's landed #493 migration. Closes the largest remaining GTK/TUI architectural divergence (TUI's ~2,100-line hand-rolled `event_loop()` vs. GTK's quadraui-owned runner). Large refactor โ€” not a quick pick, do not claim without reading the issue body's suggested incremental-landing approach. | ๐Ÿ“‹ Filed, unscoped-for-dispatch | +| **quadraui#465** | macOS backend: add `ShellApp` + `run_with_shell` composition support (mirrors the TUI/GTK `shell_runner.rs` pattern). All macOS chrome *primitives* already exist (`activity_bar`, `sidebar_panel`, `tab_bar`, โ€ฆ) โ€” this is purely the composition/runner wiring, analogous to what #595 does for TUI. **This, not #595, is the actual gate on "macOS port is a thin wrapper"** โ€” TUI already runs on macOS via crossterm and doesn't need porting for reach. | ๐Ÿ“‹ Filed on quadraui, supply-side (#5) | + +Neither blocks the other. #595 is pure vimcode-side convergence value (kills a bug +class); quadraui#465 is pure quadraui-side supply work that unblocks a *future* +native macOS backend adopting vimcode's `App: ShellApp` impl with zero new vimcode +code, once it exists. + +## Status (2026-07-21) + +- โœ… **#480 shipped โ€” the "Blocked on quadraui" table is now empty.** All #223/#224/ + #225-blocked TUI work is done; GTK got a bonus fix in the same pass (git-sidebar + header/commit-input had been unpainted since #493, see above). Filed vimcode#595 + (TUI `ShellApp` migration) and quadraui#465 (macOS `ShellApp` support) as the next + two architecture-level items โ€” see "Architecture milestones" above. Neither is a + quick #7-table pick; **#512 is still the best next quick pick** (below). - โœ… **Milestone #7 "Platform-Neutral" created** and seeded with 15 adoption issues (11 pulled out of #5, #133 out of Crate Extraction, + #146/#512/#515 from no-milestone). #5 is now scoped to pure quadraui-build. All 15 sent to the coord diff --git a/PLAN.md b/PLAN.md index fd49b1af..20a4b65a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -10,6 +10,36 @@ --- +## ๐Ÿงญ Current wave (2026-07-21) โ€” TUI โ†’ `ShellApp`/`run_with_shell` (vimcode#595) + +**Status:** filed, not yet started. See `GOALS.md`'s "Architecture milestones" +section for the full why/scope. This note exists so a future session can resume +without re-deriving the plan. + +**What's already true (don't re-derive):** +- GTK did the equivalent migration already โ€” #493, landed. `App` in `src/gtk/mod.rs` + implements `quadraui::ShellApp`; `main.rs` calls + `quadraui::gtk::shell_runner::run_with_shell(...)`. That's the pattern to mirror. +- TUI's render layer is *already* `&Engine`-shaped (immutable) via `Cell`/`RefCell` + render-time caches (`sc_panel_layout`, `explorer_tree_rect`, โ€ฆ) โ€” the part of the + migration that sounds hardest (paint needing an immutable receiver) is already done. +- The actual work is restructuring `src/tui_main/mod.rs::event_loop()` + (~2,100 lines, starts at `mod.rs:787`) into `ShellApp`'s `setup`/`render_content`/ + `handle`/`tick` shape, then swapping `tui_main::run()`'s hand-rolled raw-mode/ + terminal/frame-timing bootstrap for `quadraui::tui::shell_runner::run_with_shell`. + +**Resume steps:** +1. Read vimcode#595 in full (has the detailed scoping, non-goals, and suggested + incremental-landing approach). +2. Do **not** attempt this as one PR โ€” get a wrapper compiling against `ShellApp`'s + shape first, without cutting over the live entry point, verify parity via the + `sc_panel_tests`-style `TestBackend` regression pattern (`src/tui_main/panels.rs`) + plus manual smoke testing, *then* cut `main.rs`/`tui_bin.rs` over. +3. Not blocked on quadraui#465 (macOS `ShellApp` support) โ€” that's an independent, + parallel supply-side item; TUI already runs on macOS via crossterm regardless. + +--- + ## ๐Ÿงญ Course correction (Session 346) โ€” harness-first quadraui **The premise.** Quadraui's core promise is "primitives that work without diff --git a/src/gtk/draw.rs b/src/gtk/draw.rs index 732c5d65..6367e845 100644 --- a/src/gtk/draw.rs +++ b/src/gtk/draw.rs @@ -2581,344 +2581,6 @@ pub(super) fn draw_command_line( } } -#[allow(clippy::too_many_arguments)] -pub(super) fn draw_source_control_panel( - cr: &Context, - layout: &pango::Layout, - screen: &render::ScreenLayout, - theme: &Theme, - x: f64, - y: f64, - w: f64, - h: f64, - line_height: f64, - backend: &Rc>, - engine: &Engine, -) { - let Some(ref sc) = screen.source_control else { - return; - }; - - // Draw hint bar at bottom when focused. - let h = if sc.has_focus && h > line_height * 3.0 { - let hint_y = y + h - line_height; - let (hdr_r, hdr_g, hdr_b) = theme.status_bg.to_cairo(); - cr.set_source_rgb(hdr_r, hdr_g, hdr_b); - cr.rectangle(x, hint_y, w, line_height); - cr.fill().ok(); - let hint_text = " Press '?' for help"; - let (dim_r, dim_g, dim_b) = theme.line_number_fg.to_cairo(); - cr.set_source_rgb(dim_r, dim_g, dim_b); - layout.set_text(hint_text); - layout.set_attributes(None); - let (_, lh) = layout.pixel_size(); - cr.move_to(x + 2.0, hint_y + (line_height - lh as f64) / 2.0); - pangocairo::show_layout(cr, layout); - h - line_height - } else { - h - }; - - let (bg_r, bg_g, bg_b) = theme.tab_bar_bg.to_cairo(); - let (hdr_r, hdr_g, hdr_b) = theme.status_bg.to_cairo(); - let (hdr_fg_r, hdr_fg_g, hdr_fg_b) = theme.status_fg.to_cairo(); - let (fg_r, fg_g, fg_b) = theme.foreground.to_cairo(); - let (dim_r, dim_g, dim_b) = theme.line_number_fg.to_cairo(); - let (add_r, add_g, add_b) = theme.diff_added_bg.to_cairo(); - let (del_r, del_g, del_b) = theme.diff_removed_bg.to_cairo(); - - // Background - cr.set_source_rgb(bg_r, bg_g, bg_b); - cr.rectangle(x, y, w, h); - cr.fill().ok(); - - layout.set_attributes(None); - - let mut row: usize = 0; - - // โ”€โ”€ Row 0: header "SOURCE CONTROL" โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - cr.set_source_rgb(hdr_r, hdr_g, hdr_b); - cr.rectangle(x, y + row as f64 * line_height, w, line_height); - cr.fill().ok(); - - let branch_str = format!( - " {} SOURCE CONTROL {} โ†‘{}โ†“{}", - icons::GIT_BRANCH.nerd, - sc.branch, - sc.ahead, - sc.behind - ); - cr.set_source_rgb(hdr_fg_r, hdr_fg_g, hdr_fg_b); - layout.set_text(&branch_str); - let (lw, lh) = layout.pixel_size(); - cr.move_to( - x + 2.0, - y + row as f64 * line_height + (line_height - lh as f64) / 2.0, - ); - pangocairo::show_layout(cr, layout); - let _ = (lw, lh); - row += 1; - - // Vertical gap after header. - let gap = (line_height * 0.3).round(); - - // โ”€โ”€ Row 1+: commit input row(s) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Use float y_commit to track position with gaps. - let mut y_commit = y + row as f64 * line_height + gap; - if y_commit < y + h { - let lines: Vec<&str> = sc.commit_message.split('\n').collect(); - let commit_rows = lines.len().max(1); - let commit_h = commit_rows as f64 * line_height; - let (inp_bg_r, inp_bg_g, inp_bg_b) = if sc.commit_input_active { - theme.fuzzy_selected_bg.to_cairo() - } else { - theme.completion_bg.to_cairo() - }; - // Draw background for all commit input rows with horizontal margin. - let margin = 4.0; - cr.set_source_rgb(inp_bg_r, inp_bg_g, inp_bg_b); - cr.rectangle(x + margin, y_commit, w - margin * 2.0, commit_h); - cr.fill().ok(); - - let (prompt_r, prompt_g, prompt_b) = if sc.commit_input_active { - (fg_r, fg_g, fg_b) - } else { - (dim_r, dim_g, dim_b) - }; - cr.set_source_rgb(prompt_r, prompt_g, prompt_b); - - // Compute cursor line/col for active input. - let (cursor_line, cursor_col) = if sc.commit_input_active { - let before_cursor = &sc.commit_message[..sc.commit_cursor.min(sc.commit_message.len())]; - let cl = before_cursor.matches('\n').count(); - let line_start = before_cursor.rfind('\n').map(|i| i + 1).unwrap_or(0); - (cl, before_cursor[line_start..].chars().count()) - } else { - (0, 0) - }; - let prefix_s = format!(" {} ", icons::GIT_EDIT.nerd); - let prefix = prefix_s.as_str(); - let pad_str = " "; // 4 spaces โ€” same visual width as prefix - - if sc.commit_message.is_empty() && !sc.commit_input_active { - let prompt = format!("{}Message (press c to type)", prefix); - layout.set_text(&prompt); - let (_, lh2) = layout.pixel_size(); - cr.move_to( - x + margin + 2.0, - y_commit + (line_height - lh2 as f64) / 2.0, - ); - pangocairo::show_layout(cr, layout); - } else { - for (i, line) in lines.iter().enumerate() { - let pfx = if i == 0 { prefix } else { pad_str }; - let text = format!("{}{}", pfx, line); - layout.set_text(&text); - let (_, lh2) = layout.pixel_size(); - let row_y = y_commit + i as f64 * line_height + (line_height - lh2 as f64) / 2.0; - cr.move_to(x + margin + 2.0, row_y); - pangocairo::show_layout(cr, layout); - - // Draw beam cursor (thin vertical line) at cursor position. - if sc.commit_input_active && i == cursor_line { - let pfx_len = pfx.chars().count(); - let before_cursor_text: String = - text.chars().take(pfx_len + cursor_col).collect(); - layout.set_text(&before_cursor_text); - let (cursor_px, _) = layout.pixel_size(); - cr.set_source_rgb(fg_r, fg_g, fg_b); - cr.rectangle( - x + margin + 2.0 + cursor_px as f64, - y_commit + i as f64 * line_height, - 1.5, - line_height, - ); - cr.fill().ok(); - cr.set_source_rgb(prompt_r, prompt_g, prompt_b); - } - } - } - y_commit += commit_h; - } - - // โ”€โ”€ Bottom slab: toolbar slot + sections via SidebarPanel (#509) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Passes the entire remaining area (from just below commit input) to - // draw_sc_sidebar_panel. The backend reserves one `line_height` row for - // the toolbar slot; content_bounds below receives the sections. No manual - // btn_pad / btn_h arithmetic โ€” option (a): tighter layout, single source - // of truth in the cached SidebarPanelLayout. - { - let slab_h = (y + h - y_commit).max(0.0); - let slab_rect = quadraui::Rect::new(x as f32, y_commit as f32, w as f32, slab_h as f32); - backend.borrow_mut().enter_frame_scope(cr, layout, |b| { - b.set_current_theme(super::quadraui_gtk::q_theme(theme)); - b.set_current_line_height(line_height); - render::draw_sc_sidebar_panel(b, engine, sc, slab_rect); - }); - } - - // Section rendering โ€” migrated to SidebarSystem (#321). - // Read content_bounds from the layout that draw_sc_sidebar_panel just cached. - let _ = (add_r, add_g, add_b, del_r, del_g, del_b); - let body_rect = { - let l = engine.sc_panel_layout.borrow(); - l.as_ref().map(|l| l.content_bounds).unwrap_or( - // Fallback: old arithmetic for the first frame before layout is cached. - quadraui::Rect::new(x as f32, (y_commit + line_height) as f32, w as f32, 0.0), - ) - }; - engine.sc_sidebar_body_rect.set(body_rect); - render::populate_sc_sidebar_system(engine, theme); - { - backend.borrow_mut().enter_frame_scope(cr, layout, |b| { - b.set_current_theme(super::quadraui_gtk::q_theme(theme)); - b.set_current_line_height(line_height); - engine.sc_sidebar_system.borrow().render(b, body_rect); - }); - } - - // โ”€โ”€ Branch picker / create overlay โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if let Some(ref bp) = sc.branch_picker { - let popup_w = w.min(300.0); - let popup_h = if bp.create_mode { - line_height * 3.0 - } else { - (line_height * (bp.results.len() as f64 + 3.0)).min(h - line_height * 2.0) - }; - let popup_x = x + (w - popup_w) / 2.0; - let popup_y = y + line_height * 2.0; - - // Background - let (r, g, b) = theme.completion_bg.to_cairo(); - cr.set_source_rgb(r, g, b); - cr.rectangle(popup_x, popup_y, popup_w, popup_h); - cr.fill().ok(); - // Border - let (r, g, b) = theme.completion_border.to_cairo(); - cr.set_source_rgb(r, g, b); - cr.set_line_width(1.0); - cr.rectangle(popup_x, popup_y, popup_w, popup_h); - cr.stroke().ok(); - - // Title - let title = if bp.create_mode { - "New Branch" - } else { - "Switch Branch" - }; - let (r, g, b) = theme.completion_fg.to_cairo(); - cr.set_source_rgb(r, g, b); - layout.set_text(title); - layout.set_attributes(None); - cr.move_to(popup_x + 8.0, popup_y); - pangocairo::show_layout(cr, layout); - - if bp.create_mode { - let input_text = format!("Name: {}โ–", bp.create_input); - layout.set_text(&input_text); - cr.move_to(popup_x + 8.0, popup_y + line_height); - pangocairo::show_layout(cr, layout); - } else { - // Query row - let query_text = format!("{} {}", icons::SEARCH.nerd, bp.query); - let (r, g, b) = theme.completion_fg.to_cairo(); - cr.set_source_rgb(r, g, b); - layout.set_text(&query_text); - layout.set_attributes(None); - cr.move_to(popup_x + 8.0, popup_y + line_height); - pangocairo::show_layout(cr, layout); - - // Branch list - for (i, (name, is_current)) in bp.results.iter().enumerate() { - let ry = popup_y + line_height * (i as f64 + 2.0); - if ry + line_height > popup_y + popup_h { - break; - } - // Selection highlight - if i == bp.selected { - let (r, g, b) = theme.completion_selected_bg.to_cairo(); - cr.set_source_rgb(r, g, b); - cr.rectangle(popup_x + 1.0, ry, popup_w - 2.0, line_height); - cr.fill().ok(); - } - let marker = if *is_current { "โ— " } else { " " }; - let display = format!("{marker}{name}"); - let (r, g, b) = theme.completion_fg.to_cairo(); - cr.set_source_rgb(r, g, b); - layout.set_text(&display); - layout.set_attributes(None); - cr.move_to(popup_x + 8.0, ry); - pangocairo::show_layout(cr, layout); - } - } - } - - // โ”€โ”€ Help dialog overlay โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if sc.help_open { - let bindings: &[(&str, &str)] = &[ - ("j/k", "Navigate"), - ("s", "Stage / unstage"), - ("S", "Stage all"), - ("d", "Discard file"), - ("D", "Discard all unstaged"), - ("c", "Commit message"), - ("b", "Switch branch"), - ("B", "Create branch"), - ("p", "Push"), - ("P", "Pull"), - ("f", "Fetch"), - ("r", "Refresh"), - ("Tab", "Expand / collapse"), - ("Enter", "Open file"), - ("q/Esc", "Close panel"), - ]; - let popup_w = w.min(280.0); - let popup_h = line_height * (bindings.len() as f64 + 2.0); - let popup_x = x + (w - popup_w) / 2.0; - let popup_y = y + (h - popup_h) / 2.0; - - let (r, g, b) = theme.completion_bg.to_cairo(); - cr.set_source_rgb(r, g, b); - cr.rectangle(popup_x, popup_y, popup_w, popup_h); - cr.fill().ok(); - let (r, g, b) = theme.completion_border.to_cairo(); - cr.set_source_rgb(r, g, b); - cr.set_line_width(1.0); - cr.rectangle(popup_x, popup_y, popup_w, popup_h); - cr.stroke().ok(); - - // Title + close hint - let (r, g, b) = theme.completion_fg.to_cairo(); - cr.set_source_rgb(r, g, b); - layout.set_text("Keybindings"); - layout.set_attributes(None); - cr.move_to(popup_x + 8.0, popup_y); - pangocairo::show_layout(cr, layout); - - layout.set_text("x"); - cr.move_to(popup_x + popup_w - 16.0, popup_y); - pangocairo::show_layout(cr, layout); - - // Bindings - for (i, (key, desc)) in bindings.iter().enumerate() { - let ry = popup_y + line_height * (i as f64 + 1.0); - let (r, g, b) = theme.function.to_cairo(); - cr.set_source_rgb(r, g, b); - layout.set_text(key); - layout.set_attributes(None); - cr.move_to(popup_x + 12.0, ry); - pangocairo::show_layout(cr, layout); - - let (r, g, b) = theme.completion_fg.to_cairo(); - cr.set_source_rgb(r, g, b); - layout.set_text(desc); - layout.set_attributes(None); - cr.move_to(popup_x + 100.0, ry); - pangocairo::show_layout(cr, layout); - } - } -} - // โ”€โ”€โ”€ Settings sidebar panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /// Phase A.3c-2: settings panel renders into a `DrawingArea` via the diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index af60d597..5a153fbf 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -8169,19 +8169,81 @@ impl quadraui::ShellApp for App { } PANEL_GIT => { if let Some(ref sc) = screen.source_control { - // Render the toolbar-slab + section list; the header row - // (branch name) and commit-input chrome are deferred to a - // follow-up migration once a Backend primitive for them lands. - render::draw_sc_sidebar_panel(backend, &engine, sc, q_sb); + // Header row + commit-input box (#480). Previously + // entirely unpainted under ShellApp โ€” the only place + // that ever drew them was the dead + // `draw.rs::draw_source_control_panel` Cairo painter, + // which has zero live callers (superseded by this + // `render_content` path back when the 14 legacy DAs + // were collapsed into one, #493). Paint them for + // real now that quadraui#222 (TextInput) has landed, + // through the same `render::sc_*` adapters TUI uses + // so the two renderers can't drift. + let header_h = lh as f32; + let header_rect = quadraui::Rect::new(q_sb.x, q_sb.y, q_sb.width, header_h); + let header_bar = render::sc_header_status_bar(sc, &theme); + let _ = backend.draw_status_bar(header_rect, &header_bar, None, None); + + let ti = render::sc_commit_message_to_text_input(sc); + let commit_rows = render::sc_commit_input_row_count(&sc.commit_message); + // +2px for the primitive's 1px border top+bottom โ€” GTK's + // native unit is pixels, unlike TUI's whole-cell border + // (see `render::sc_commit_input_box_height` doc). + let commit_h = commit_rows as f32 * lh as f32 + 2.0; + let ti_rect = + quadraui::Rect::new(q_sb.x, q_sb.y + header_h, q_sb.width, commit_h); + backend.draw_text_input(ti_rect, &ti); + + // Render the toolbar-slab + section list below the + // header + commit input. + let slab_y = q_sb.y + header_h + commit_h; + let slab_rect = quadraui::Rect::new( + q_sb.x, + slab_y, + q_sb.width, + (q_sb.height - header_h - commit_h).max(0.0), + ); + render::draw_sc_sidebar_panel(backend, &engine, sc, slab_rect); let body_rect = engine .sc_panel_layout .borrow() .as_ref() .map(|l| l.content_bounds) - .unwrap_or(q_sb); + .unwrap_or(slab_rect); engine.sc_sidebar_body_rect.set(body_rect); render::populate_sc_sidebar_system(&engine, &theme); engine.sc_sidebar_system.borrow().render(backend, body_rect); + + // Branch picker / create popup (dual-mode Palette, + // quadraui#224) and help dialog (Dialog + DialogTable, + // quadraui#225) โ€” both keyboard-reachable via + // `dispatch_sc_sidebar_key_unified` even though the + // git sidebar has no live mouse-click routing yet + // (#449 tracks that separately). Render over the + // whole sidebar content area, same popup-over-panel + // z-order TUI uses. + if let Some(ref bp) = sc.branch_picker { + let palette = render::sc_branch_picker_to_palette(bp); + let popup_w = q_sb.width.min(40.0 * cw as f32); + let popup_h = if bp.create_mode { + 4.0 * lh as f32 + } else { + (q_sb.height * 0.6).min(15.0 * lh as f32) + }; + let popup_x = q_sb.x + (q_sb.width - popup_w) / 2.0; + let popup_y = q_sb.y + 2.0 * lh as f32; + backend.draw_palette( + quadraui::Rect::new(popup_x, popup_y, popup_w, popup_h), + &palette, + ); + } + + if sc.help_open { + let viewport = q_sb; + let (dialog, dlayout) = + render::sc_help_dialog_layout(viewport, cw as f32, lh as f32); + backend.draw_dialog(&dialog, &dlayout); + } } } PANEL_EXTENSIONS => { diff --git a/src/render.rs b/src/render.rs index 75eba110..1a1ee3d1 100644 --- a/src/render.rs +++ b/src/render.rs @@ -6908,18 +6908,6 @@ fn to_q_color(c: Color) -> quadraui::Color { quadraui::Color::rgb(c.r, c.g, c.b) } -/// Adapt a `SourceControlData` (vimcode's internal representation) into a -/// generic `quadraui::TreeView` that backends can render through the shared -/// tree-primitive drawing path. -/// -/// Scope: covers the four expandable sections only โ€” Staged, Changes, -/// Worktrees, and Recent Commits. The header row, commit input, and action -/// button row remain the responsibility of the existing SC panel code; -/// they will migrate in later A.x stages when their primitives land. -/// -/// Row order mirrors `render_source_control()` in the TUI so `sc.selected` -/// (a flat row index within the sections area) maps one-to-one onto the -/// returned `TreeView.rows`. /// Build the Source Control action-button row as a `quadraui::Toolbar` /// (#505). Commit carries its label + `(c)` key hint and is disabled while /// the commit message is empty; Push/Pull/Sync are icon-only. Button ids @@ -6994,6 +6982,277 @@ pub fn draw_sc_sidebar_panel( engine.sc_panel_layout.replace(Some(layout)); } +/// Format the SC panel's header row text: branch name + ahead/behind +/// counts when present. Shared by both backends so the header text can't +/// drift between TUI and GTK renderers (#480). +pub fn sc_header_text(sc: &SourceControlData) -> String { + if sc.ahead > 0 || sc.behind > 0 { + format!( + " \u{e702} SOURCE CONTROL {} \u{2191}{} \u{2193}{}", + sc.branch, sc.ahead, sc.behind + ) + } else { + format!(" \u{e702} SOURCE CONTROL {}", sc.branch) + } +} + +/// Build the SC panel's header row as a single-segment `quadraui::StatusBar` +/// (#480). GTK paints the header through this โ€” TUI keeps its existing +/// direct `set_cell` text row (both read the same [`sc_header_text`] +/// string, so the two can't show different branch info even though the +/// paint mechanism differs). +pub fn sc_header_status_bar(sc: &SourceControlData, theme: &Theme) -> quadraui::StatusBar { + quadraui::StatusBar { + id: quadraui::WidgetId::new("sc:header"), + left_segments: vec![quadraui::StatusBarSegment { + text: sc_header_text(sc), + fg: to_quadraui_color(theme.status_fg), + bg: to_quadraui_color(theme.status_bg), + bold: false, + action_id: None, + }], + right_segments: Vec::new(), + } +} + +/// Number of text rows in the SC commit message (at least 1, even when +/// empty). Shared raw line count โ€” both backends derive their own +/// border/line-height-aware box height from this (#480). +pub fn sc_commit_input_row_count(commit_message: &str) -> u16 { + commit_message.split('\n').count().max(1) as u16 +} + +/// Height in *rows* of the SC commit-input box on TUI, including the +/// `TextInput` primitive's 1-cell border on top and bottom (#480). TUI's +/// native unit is one screen cell, so the border costs exactly 2 whole +/// rows โ€” this is the single source of truth shared by TUI's paint code +/// (`panels.rs`) and its click hit-test math (`mouse.rs`), so the two +/// can't drift out of sync the way the pre-migration hand-rolled geometry +/// did. GTK's native unit is pixels, where the same 1-*pixel* border is +/// negligible next to a `line_height` row โ€” GTK computes its box height +/// directly from [`sc_commit_input_row_count`] instead of this function. +pub fn sc_commit_input_box_height(commit_message: &str) -> u16 { + sc_commit_input_row_count(commit_message) + 2 +} + +/// Adapt the SC commit-message state into a `quadraui::TextInput` (#480, +/// migrating the hand-rolled `set_cell` commit-row painter to the shared +/// primitive shipped in quadraui#222). +/// +/// Converts the engine's byte-offset cursor (`sc.commit_cursor`, an index +/// into the flat `\n`-joined `commit_message` string) into the +/// primitive's `(cursor_line, cursor_col)` char-column coordinates. +/// Render-only: the engine's `handle_sc_commit_input_key` remains the sole +/// owner of edit logic โ€” this function only builds a paint-time snapshot. +pub fn sc_commit_message_to_text_input(sc: &SourceControlData) -> quadraui::TextInput { + use quadraui::{TextInput, WidgetId}; + + let byte_cursor = sc.commit_cursor.min(sc.commit_message.len()); + let before = &sc.commit_message[..byte_cursor]; + let cursor_line = before.matches('\n').count(); + let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0); + let cursor_col = before[line_start..].chars().count(); + + let lines: Vec = if sc.commit_message.is_empty() { + vec![String::new()] + } else { + sc.commit_message.split('\n').map(str::to_string).collect() + }; + + TextInput { + id: WidgetId::new("sc:commit_input"), + lines, + cursor_line, + cursor_col, + // Only shown while not actively editing an empty message โ€” matches + // the pre-migration behaviour of hiding the prompt text as soon as + // the cursor is live in an empty input. + placeholder: if sc.commit_input_active { + None + } else { + Some("Message (press c)".to_string()) + }, + scroll_offset: 0, + scroll_col: 0, + has_focus: sc.commit_input_active, + } +} + +/// Adapt the SC branch-picker popup state into a dual-mode +/// `quadraui::Palette` (#480, migrating the hand-rolled popup to the +/// primitive shipped in quadraui#224). `create_mode` maps to +/// `PaletteMode::Input` (free-text new-branch name); otherwise +/// `PaletteMode::List` with the fuzzy-filtered branch results, current +/// branch marked with a leading bullet. +/// +/// Render-only, same as [`sc_commit_message_to_text_input`]: query/cursor +/// editing and selection remain owned by `Engine::handle_sc_branch_picker_key` +/// / `handle_sc_branch_create_key` โ€” this is purely a paint-time snapshot, +/// not an adoption of `DualModePaletteController`'s own (would-be +/// duplicate) key-handling state machine. +pub fn sc_branch_picker_to_palette(bp: &BranchPickerData) -> quadraui::Palette { + use quadraui::{Palette, PaletteItem, PaletteMode, StyledText, WidgetId}; + + if bp.create_mode { + return Palette { + id: WidgetId::new("sc:branch_picker"), + title: "New Branch".to_string(), + query: bp.create_input.clone(), + query_cursor: bp.create_input.len(), + items: Vec::new(), + selected_idx: 0, + scroll_offset: 0, + total_count: 0, + has_focus: true, + show_query: true, + create_label: None, + preview: None, + mode: PaletteMode::Input, + }; + } + + let items: Vec = bp + .results + .iter() + .map(|(name, is_current)| PaletteItem { + text: StyledText::plain(if *is_current { + format!("\u{25cf} {name}") + } else { + format!(" {name}") + }), + detail: None, + icon: None, + match_positions: Vec::new(), + depth: 0, + expandable: false, + expanded: false, + }) + .collect(); + + Palette { + id: WidgetId::new("sc:branch_picker"), + title: "Switch Branch".to_string(), + query: bp.query.clone(), + query_cursor: bp.query.len(), + items, + selected_idx: bp.selected, + scroll_offset: 0, + total_count: 0, + has_focus: true, + show_query: true, + create_label: None, + preview: None, + mode: PaletteMode::List, + } +} + +/// Static keybindings table for the SC help dialog (#480, migrating the +/// hand-rolled 2-column popup to `Dialog` + `DialogTable`, shipped in +/// quadraui#225). Shared by both backends so the bindings list has one +/// source of truth. +pub fn sc_help_dialog() -> quadraui::Dialog { + use quadraui::{Dialog, DialogButton, DialogTable, StyledText, WidgetId}; + + const BINDINGS: &[(&str, &str)] = &[ + ("j/k", "Navigate"), + ("s", "Stage / unstage"), + ("S", "Stage all"), + ("d", "Discard file"), + ("D", "Discard all unstaged"), + ("c", "Commit message"), + ("b", "Switch branch"), + ("B", "Create branch"), + ("p", "Push"), + ("P", "Pull"), + ("f", "Fetch"), + ("r", "Refresh"), + ("Tab", "Expand / collapse"), + ("Enter", "Open file"), + ("q/Esc", "Close panel"), + ]; + + Dialog { + id: WidgetId::new("sc:help"), + title: StyledText::plain("Keybindings"), + body: Vec::new(), + buttons: vec![DialogButton { + id: WidgetId::new("sc:help:close"), + label: "Close".to_string(), + is_default: true, + is_cancel: true, + tint: None, + }], + severity: None, + vertical_buttons: false, + table: Some(DialogTable { + headers: Some(vec!["Key".to_string(), "Action".to_string()]), + rows: BINDINGS + .iter() + .map(|(k, d)| vec![k.to_string(), d.to_string()]) + .collect(), + column_widths: None, + }), + input: None, + } +} + +/// Compute the `DialogLayout` for [`sc_help_dialog`] from generic +/// char-cell/pixel metrics (TUI: `1.0, 1.0`; GTK: real `char_width`/ +/// `line_height`, #546-style dual-backend convention). Mirrors +/// `dialog_generic_layout`'s char-cell approximation formula, but sized +/// from the table's own `tui_total_width`/`tui_total_height` helpers +/// since this dialog has no body text driving its width. +pub fn sc_help_dialog_layout( + viewport: quadraui::Rect, + char_width: f32, + line_height: f32, +) -> (quadraui::Dialog, quadraui::DialogLayout) { + let dialog = sc_help_dialog(); + let table = dialog + .table + .as_ref() + .expect("sc_help_dialog always sets `table`"); + + let table_h = table.tui_total_height() as f32 * line_height; + let table_w = table.tui_total_width() as f32 * char_width + char_width * 2.0; + + let min_w = char_width * 30.0; + let max_w = char_width * 60.0; + let default_w = (viewport.width * 0.5).clamp(min_w, max_w); + let width = default_w + .max(table_w) + .min(viewport.width - char_width * 4.0); + + let measure = quadraui::DialogMeasure { + width, + title_height: line_height, + body_height: 0.0, + table_height: table_h, + input_height: 0.0, + button_row_height: line_height, + button_width: char_width * 8.0, + button_gap: char_width * 2.0, + padding: line_height, + }; + let layout = dialog.layout(viewport, measure, |_| { + quadraui::ToolbarItemMeasure::new(0.0) + }); + (dialog, layout) +} + +/// Adapt a `SourceControlData` (vimcode's internal representation) into a +/// generic `quadraui::TreeView` that backends can render through the shared +/// tree-primitive drawing path. +/// +/// Scope: covers the four expandable sections only โ€” Staged, Changes, +/// Worktrees, and Recent Commits. The header row, commit input, branch +/// picker, help dialog, and action button row are built by their own +/// dedicated adapters (`sc_header_text`, `sc_commit_message_to_text_input`, +/// `sc_branch_picker_to_palette`, `sc_help_dialog`, `sc_button_toolbar`). +/// +/// Row order mirrors `render_source_control()` in the TUI so `sc.selected` +/// (a flat row index within the sections area) maps one-to-one onto the +/// returned `TreeView.rows`. pub fn source_control_to_tree_view(sc: &SourceControlData, theme: &Theme) -> quadraui::TreeView { use quadraui::{ Badge, Decoration, SelectionMode, StyledSpan, StyledText, TreeRow, TreeStyle, TreeView, diff --git a/src/tui_main/mouse.rs b/src/tui_main/mouse.rs index 31f4f7a4..3d346e85 100644 --- a/src/tui_main/mouse.rs +++ b/src/tui_main/mouse.rs @@ -2478,11 +2478,12 @@ pub(super) fn handle_mouse( // sidebar_row layout after #509 (option a, no padding): // 0 = header - // 1 .. commit_end = commit input + // 1 .. commit_end = commit input (quadraui::TextInput box, + // including its 1-row border top+bottom โ€” #480) // commit_end = toolbar slot (button row, SidebarPanel) // commit_end+1 .. = sections (SidebarPanel content area) - let commit_rows = engine.sc_commit_message.split('\n').count().max(1) as u16; - let commit_end = 1 + commit_rows; + let commit_box_h = render::sc_commit_input_box_height(&engine.sc_commit_message); + let commit_end = 1 + commit_box_h; if sidebar_row == 0 { engine.sc_commit_input_active = false; diff --git a/src/tui_main/panels.rs b/src/tui_main/panels.rs index 219b0138..ca3dd44a 100644 --- a/src/tui_main/panels.rs +++ b/src/tui_main/panels.rs @@ -298,10 +298,7 @@ pub(super) fn render_source_control( } } } - let item_fg = rc(theme.foreground); let dim_fg = rc(theme.line_number_fg); - let sel_bg = rc(theme.fuzzy_selected_bg); - let row_bg = rc(theme.tab_bar_bg); // Build SC data from engine state via the render abstraction. let screen = render::build_screen_layout(engine, theme, &[], 1.0, 1.0, true); @@ -330,14 +327,7 @@ pub(super) fn render_source_control( }; // โ”€โ”€ Row 0: header "SOURCE CONTROL" โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - let branch_info = if sc.ahead > 0 || sc.behind > 0 { - format!( - " \u{e702} SOURCE CONTROL {} \u{2191}{} \u{2193}{}", - sc.branch, sc.ahead, sc.behind - ) - } else { - format!(" \u{e702} SOURCE CONTROL {}", sc.branch) - }; + let branch_info = render::sc_header_text(sc); for x in area.x..area.x + area.width { set_cell(buf, x, area.y, ' ', hdr_fg, hdr_bg); } @@ -349,81 +339,30 @@ pub(super) fn render_source_control( return; } - // โ”€โ”€ Row 1+: commit input row(s) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - let commit_lines: Vec<&str> = sc.commit_message.split('\n').collect(); - let commit_rows = commit_lines.len().max(1) as u16; + // โ”€โ”€ Row 1+: commit input box (quadraui::TextInput, #480) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Migrated from a hand-rolled `set_cell` multi-line editor to the shared + // `TextInput` primitive (quadraui#222). `commit_box_h` includes the + // primitive's 1-row border on top and bottom โ€” see + // `render::sc_commit_input_box_height` doc for why this height is the + // single source of truth shared with `mouse.rs`'s click hit-test. + let ti = render::sc_commit_message_to_text_input(sc); + let commit_box_h = render::sc_commit_input_box_height(&sc.commit_message); { - let inp_bg = if sc.commit_input_active { - sel_bg - } else { - row_bg - }; - let prompt_fg = if sc.commit_input_active { - item_fg - } else { - dim_fg - }; - - // Compute cursor line/col for active input. - let (cursor_line, cursor_col) = if sc.commit_input_active { - let before_cursor = &sc.commit_message[..sc.commit_cursor.min(sc.commit_message.len())]; - let cl = before_cursor.matches('\n').count(); - let line_start = before_cursor.rfind('\n').map(|i| i + 1).unwrap_or(0); - (cl, before_cursor[line_start..].chars().count()) - } else { - (0, 0) - }; - let prefix = " \u{f044} "; - let pad = " "; // 4 spaces โ€” same visual width as prefix - - if sc.commit_message.is_empty() && !sc.commit_input_active { - let commit_y = area.y + 1; - let prompt = format!("{}Message (press c)", prefix); - for x in area.x..area.x + area.width { - set_cell(buf, x, commit_y, ' ', prompt_fg, inp_bg); - } - for (i, ch) in prompt.chars().enumerate().take(area.width as usize) { - set_cell(buf, area.x + i as u16, commit_y, ch, prompt_fg, inp_bg); - } - } else { - for (line_idx, line) in commit_lines.iter().enumerate() { - let commit_y = area.y + 1 + line_idx as u16; - if commit_y >= area.y + area.height { - break; - } - for x in area.x..area.x + area.width { - set_cell(buf, x, commit_y, ' ', prompt_fg, inp_bg); - } - let pfx = if line_idx == 0 { prefix } else { pad }; - let text = format!("{}{}", pfx, line); - let pfx_len = pfx.chars().count(); - for (i, ch) in text.chars().enumerate().take(area.width as usize) { - // Show cursor by inverting fg/bg at cursor position. - let (fg, bg) = if sc.commit_input_active - && line_idx == cursor_line - && i == pfx_len + cursor_col - { - (inp_bg, prompt_fg) - } else { - (prompt_fg, inp_bg) - }; - set_cell(buf, area.x + i as u16, commit_y, ch, fg, bg); - } - // If cursor is at end of line, show inverted space after text. - if sc.commit_input_active - && line_idx == cursor_line - && cursor_col >= line.chars().count() - { - let cx = area.x + (pfx_len + cursor_col) as u16; - if cx < area.x + area.width { - set_cell(buf, cx, commit_y, ' ', inp_bg, prompt_fg); - } - } - } - } + let paint_h = commit_box_h.min(area.height.saturating_sub(1)); + let ti_rect = quadraui::Rect::new( + area.x as f32, + (area.y + 1) as f32, + area.width as f32, + paint_h as f32, + ); + backend.set_current_theme(super::quadraui_tui::q_theme(theme)); + backend.enter_frame_scope(frame, |b| { + use quadraui::Backend; + b.draw_text_input(ti_rect, &ti); + }); } - if area.height < 1 + commit_rows + 2 { + if area.height < 1 + commit_box_h { return; } @@ -434,7 +373,7 @@ pub(super) fn render_source_control( // per-side padding rows โ€” option (a) from the issue: tighter layout, // zero manual arithmetic. { - let slab_y = area.y + 1 + commit_rows; + let slab_y = area.y + 1 + commit_box_h; let slab_h = (area.y + area.height).saturating_sub(slab_y); let slab_rect = quadraui::Rect::new( area.x as f32, @@ -453,7 +392,7 @@ pub(super) fn render_source_control( let l = engine.sc_panel_layout.borrow(); l.as_ref() .map(|l| l.content_bounds.y as u16) - .unwrap_or(area.y + 2 + commit_rows) // fallback: btn row + 1 + .unwrap_or(area.y + 1 + commit_box_h + 1) // fallback: btn row + 1 }; if section_start_y >= area.y + area.height { return; @@ -478,226 +417,48 @@ pub(super) fn render_source_control( backend.enter_frame_scope(frame, |b| { engine.sc_sidebar_system.borrow().render(b, q_rect); }); - let buf = frame.buffer_mut(); - - // โ”€โ”€ Branch picker / create popup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // โ”€โ”€ Branch picker / create popup (quadraui::Palette dual-mode, #480) โ”€โ”€โ”€โ”€โ”€ + // Migrated from a hand-rolled popup to the dual-mode `Palette` primitive + // shipped in quadraui#224 (list mode = switch branch, input mode = + // create branch). Scroll is authoritative in the TUI rasteriser (keeps + // `selected_idx` in view), so no manual scroll-offset math is needed + // here the way the hand-rolled version required. if let Some(ref bp) = sc.branch_picker { - let popup_bg = rc(theme.completion_bg); - let popup_fg = rc(theme.completion_fg); - let popup_border = rc(theme.completion_border); - let popup_sel = rc(theme.completion_selected_bg); + let palette = render::sc_branch_picker_to_palette(bp); let popup_w = area.width.saturating_sub(2).min(40); let popup_h = if bp.create_mode { - 3u16 + 4u16 } else { area.height.saturating_sub(4).min(15) }; let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; let popup_y = area.y + 2; - // Clear popup area - for y in popup_y..popup_y + popup_h { - for x in popup_x..popup_x + popup_w { - set_cell(buf, x, y, ' ', popup_fg, popup_bg); - } - } - // Top border - if popup_w >= 2 { - set_cell(buf, popup_x, popup_y, 'โ”Œ', popup_border, popup_bg); - set_cell( - buf, - popup_x + popup_w - 1, - popup_y, - 'โ”', - popup_border, - popup_bg, - ); - for x in popup_x + 1..popup_x + popup_w - 1 { - set_cell(buf, x, popup_y, 'โ”€', popup_border, popup_bg); - } - let title = if bp.create_mode { - " New Branch " - } else { - " Switch Branch " - }; - let title_x = popup_x + 1; - for (i, ch) in title.chars().enumerate() { - let x = title_x + i as u16; - if x < popup_x + popup_w - 1 { - set_cell(buf, x, popup_y, ch, popup_border, popup_bg); - } - } - } - if bp.create_mode { - let iy = popup_y + 1; - let label = "Name: "; - for (i, ch) in label.chars().enumerate() { - let x = popup_x + 1 + i as u16; - if x < popup_x + popup_w - 1 { - set_cell(buf, x, iy, ch, dim_fg, popup_bg); - } - } - let input_x = popup_x + 1 + label.len() as u16; - for (i, ch) in bp.create_input.chars().enumerate() { - let x = input_x + i as u16; - if x < popup_x + popup_w - 1 { - set_cell(buf, x, iy, ch, popup_fg, popup_bg); - } - } - let cx = input_x + bp.create_input.len() as u16; - if cx < popup_x + popup_w - 1 { - set_cell(buf, cx, iy, 'โ–', popup_fg, popup_bg); - } - let by = popup_y + popup_h - 1; - set_cell(buf, popup_x, by, 'โ””', popup_border, popup_bg); - set_cell(buf, popup_x + popup_w - 1, by, 'โ”˜', popup_border, popup_bg); - for x in popup_x + 1..popup_x + popup_w - 1 { - set_cell(buf, x, by, 'โ”€', popup_border, popup_bg); - } - } else { - let iy = popup_y + 1; - let prefix = " \u{f002} "; - for (i, ch) in prefix.chars().enumerate() { - let x = popup_x + i as u16; - if x < popup_x + popup_w { - set_cell(buf, x, iy, ch, dim_fg, popup_bg); - } - } - let qx = popup_x + prefix.chars().count() as u16; - for (i, ch) in bp.query.chars().enumerate() { - let x = qx + i as u16; - if x < popup_x + popup_w - 1 { - set_cell(buf, x, iy, ch, popup_fg, popup_bg); - } - } - let list_y = popup_y + 2; - let list_h = popup_h.saturating_sub(3) as usize; - let scroll_off = if bp.selected >= list_h { - bp.selected - list_h + 1 - } else { - 0 - }; - for (vi, (name, is_current)) in - bp.results.iter().skip(scroll_off).take(list_h).enumerate() - { - let y = list_y + vi as u16; - let is_sel = vi + scroll_off == bp.selected; - let bg = if is_sel { popup_sel } else { popup_bg }; - for x in popup_x..popup_x + popup_w { - set_cell(buf, x, y, ' ', popup_fg, bg); - } - let marker = if *is_current { "โ— " } else { " " }; - let display = format!("{marker}{name}"); - for (i, ch) in display.chars().enumerate() { - let x = popup_x + 1 + i as u16; - if x < popup_x + popup_w - 1 { - set_cell(buf, x, y, ch, popup_fg, bg); - } - } - } - let by = popup_y + popup_h - 1; - if by >= list_y { - set_cell(buf, popup_x, by, 'โ””', popup_border, popup_bg); - set_cell(buf, popup_x + popup_w - 1, by, 'โ”˜', popup_border, popup_bg); - for x in popup_x + 1..popup_x + popup_w - 1 { - set_cell(buf, x, by, 'โ”€', popup_border, popup_bg); - } - } - } - // Side borders - for y in popup_y + 1..popup_y + popup_h.saturating_sub(1) { - set_cell(buf, popup_x, y, 'โ”‚', popup_border, popup_bg); - if popup_x + popup_w > 0 { - set_cell(buf, popup_x + popup_w - 1, y, 'โ”‚', popup_border, popup_bg); - } - } + let q_rect = quadraui::Rect::new( + popup_x as f32, + popup_y as f32, + popup_w as f32, + popup_h as f32, + ); + backend.set_current_theme(super::quadraui_tui::q_theme(theme)); + backend.enter_frame_scope(frame, |b| { + use quadraui::Backend; + b.draw_palette(q_rect, &palette); + }); } - // โ”€โ”€ Help dialog โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // โ”€โ”€ Help dialog (quadraui::Dialog + DialogTable, #480) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Migrated from a hand-rolled 2-column popup to `Dialog`'s table slot, + // shipped in quadraui#225. Bindings list lives once in + // `render::sc_help_dialog` instead of being duplicated per backend. if sc.help_open { - let popup_bg = rc(theme.completion_bg); - let popup_fg = rc(theme.completion_fg); - let popup_border = rc(theme.completion_border); - let bindings: &[(&str, &str)] = &[ - ("j/k", "Navigate"), - ("s", "Stage / unstage"), - ("S", "Stage all"), - ("d", "Discard file"), - ("D", "Discard all unstaged"), - ("c", "Commit message"), - ("b", "Switch branch"), - ("B", "Create branch"), - ("p", "Push"), - ("P", "Pull"), - ("f", "Fetch"), - ("r", "Refresh"), - ("Tab", "Expand / collapse"), - ("Enter", "Open file"), - ("q/Esc", "Close panel"), - ]; - let popup_w = area.width.saturating_sub(2).min(36); - let popup_h = (bindings.len() as u16 + 3).min(area.height.saturating_sub(2)); - let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; - for y in popup_y..popup_y + popup_h { - for x in popup_x..popup_x + popup_w { - set_cell(buf, x, y, ' ', popup_fg, popup_bg); - } - } - set_cell(buf, popup_x, popup_y, 'โ”Œ', popup_border, popup_bg); - set_cell( - buf, - popup_x + popup_w - 1, - popup_y, - 'โ”', - popup_border, - popup_bg, + let viewport = quadraui::Rect::new( + area.x as f32, + area.y as f32, + area.width as f32, + area.height as f32, ); - for x in popup_x + 1..popup_x + popup_w - 1 { - set_cell(buf, x, popup_y, 'โ”€', popup_border, popup_bg); - } - let title = " Keybindings "; - let tx = popup_x + (popup_w.saturating_sub(title.len() as u16)) / 2; - for (i, ch) in title.chars().enumerate() { - let x = tx + i as u16; - if x > popup_x && x < popup_x + popup_w - 1 { - set_cell(buf, x, popup_y, ch, popup_border, popup_bg); - } - } - // Close hint - let close_x = popup_x + popup_w - 2; - if close_x > popup_x { - set_cell(buf, close_x, popup_y, 'x', popup_border, popup_bg); - } - let key_fg = rc(theme.function); - for (i, (key, desc)) in bindings.iter().enumerate() { - let y = popup_y + 1 + i as u16; - if y >= popup_y + popup_h - 1 { - break; - } - for (j, ch) in key.chars().enumerate() { - let x = popup_x + 2 + j as u16; - if x < popup_x + popup_w - 1 { - set_cell(buf, x, y, ch, key_fg, popup_bg); - } - } - let desc_x = popup_x + 12; - for (j, ch) in desc.chars().enumerate() { - let x = desc_x + j as u16; - if x < popup_x + popup_w - 1 { - set_cell(buf, x, y, ch, popup_fg, popup_bg); - } - } - } - let by = popup_y + popup_h - 1; - set_cell(buf, popup_x, by, 'โ””', popup_border, popup_bg); - set_cell(buf, popup_x + popup_w - 1, by, 'โ”˜', popup_border, popup_bg); - for x in popup_x + 1..popup_x + popup_w - 1 { - set_cell(buf, x, by, 'โ”€', popup_border, popup_bg); - } - for y in popup_y + 1..popup_y + popup_h - 1 { - set_cell(buf, popup_x, y, 'โ”‚', popup_border, popup_bg); - set_cell(buf, popup_x + popup_w - 1, y, 'โ”‚', popup_border, popup_bg); - } + let (dialog, layout) = render::sc_help_dialog_layout(viewport, 1.0, 1.0); + super::quadraui_tui::draw_dialog(frame.buffer_mut(), &dialog, &layout, theme); } } @@ -1644,3 +1405,166 @@ pub(super) fn render_terminal_panel( }); } } + +// โ”€โ”€โ”€ Source Control panel rendering tests (#480) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// +// Drives `render_source_control` through the same headless +// `ratatui::Terminal` harness `render_impl.rs`'s test module +// uses for full-frame rendering โ€” vimcode's equivalent of quadraui's +// `TuiDriver`. Exercises the migrated `TextInput` / dual-mode `Palette` / +// `Dialog`+`DialogTable` paint paths end-to-end (build_screen_layout โ†’ +// render_source_control โ†’ backend rasterisers) rather than only unit-testing +// the `render::sc_*` adapters in isolation, so a regression in the wiring +// (wrong rect, wrong field) would show up as a rendered-buffer mismatch. +#[cfg(test)] +mod sc_panel_tests { + use super::*; + use ratatui::backend::TestBackend; + + /// Hermetic engine with the Source Control panel active and focused. + /// Resets git-derived fields so snapshots don't depend on the repo + /// state of whatever machine/branch the test happens to run on. + fn test_engine() -> Engine { + crate::core::session::suppress_disk_saves(); + let mut e = Engine::new(); + e.settings = crate::core::settings::Settings::default(); + e.extension_state = crate::core::session::ExtensionState::default(); + e.ext_registry = None; + e.git_branch = None; + e.sc_ahead = 0; + e.sc_behind = 0; + e.sc_has_focus = true; + e.app_shell.show_panel(&quadraui::WidgetId::new(PANEL_GIT)); + e + } + + /// Render just the SC panel and return the rasterised buffer as lines. + fn render_sc(engine: &Engine, width: u16, height: u16) -> Vec { + let backend = TestBackend::new(width, height); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let theme = crate::render::Theme::onedark(); + let mut tui_backend = super::super::backend::TuiBackend::new(); + let area = Rect { + x: 0, + y: 0, + width, + height, + }; + terminal + .draw(|frame| { + render_source_control(&mut tui_backend, frame, area, engine, &theme); + }) + .unwrap(); + let buf = terminal.backend().buffer(); + (0..height) + .map(|y| { + let mut line = String::new(); + for x in 0..width { + line.push_str(buf[(x, y)].symbol()); + } + line.trim_end().to_string() + }) + .collect() + } + + fn contains(lines: &[String], substr: &str) -> bool { + lines.iter().any(|l| l.contains(substr)) + } + + #[test] + fn empty_commit_message_shows_placeholder() { + let e = test_engine(); + let lines = render_sc(&e, 40, 20); + assert!( + contains(&lines, "Message (press c)"), + "expected commit-input placeholder, got: {lines:#?}" + ); + } + + #[test] + fn active_commit_input_renders_typed_message_not_placeholder() { + let mut e = test_engine(); + e.sc_commit_message = "Fix the thing".to_string(); + e.sc_commit_cursor = e.sc_commit_message.len(); + e.sc_commit_input_active = true; + let lines = render_sc(&e, 40, 20); + assert!( + contains(&lines, "Fix the thing"), + "expected typed commit message, got: {lines:#?}" + ); + assert!( + !contains(&lines, "Message (press c)"), + "placeholder should not show while actively editing, got: {lines:#?}" + ); + } + + #[test] + fn multiline_commit_message_renders_every_line() { + let mut e = test_engine(); + e.sc_commit_message = "Summary line\n\nBody line one\nBody line two".to_string(); + e.sc_commit_cursor = 0; + e.sc_commit_input_active = true; + // Tall enough for the multi-line TextInput box + toolbar + sections. + let lines = render_sc(&e, 40, 24); + assert!(contains(&lines, "Summary line"), "{lines:#?}"); + assert!(contains(&lines, "Body line one"), "{lines:#?}"); + assert!(contains(&lines, "Body line two"), "{lines:#?}"); + } + + #[test] + fn branch_picker_list_mode_renders_branches_and_marks_current() { + let mut e = test_engine(); + e.sc_branch_picker_open = true; + e.sc_branch_picker_branches = vec![ + crate::core::git::BranchEntry { + name: "main".to_string(), + is_current: true, + upstream: None, + ahead_behind: None, + }, + crate::core::git::BranchEntry { + name: "feature/foo".to_string(), + is_current: false, + upstream: None, + ahead_behind: None, + }, + ]; + let lines = render_sc(&e, 50, 24); + assert!(contains(&lines, "Switch Branch"), "{lines:#?}"); + assert!(contains(&lines, "main"), "{lines:#?}"); + assert!(contains(&lines, "feature/foo"), "{lines:#?}"); + } + + #[test] + fn branch_picker_create_mode_renders_typed_name() { + let mut e = test_engine(); + e.sc_branch_create_mode = true; + e.sc_branch_create_input = "wip-feature".to_string(); + let lines = render_sc(&e, 50, 24); + assert!(contains(&lines, "New Branch"), "{lines:#?}"); + assert!(contains(&lines, "wip-feature"), "{lines:#?}"); + } + + #[test] + fn help_dialog_renders_keybindings_table() { + let mut e = test_engine(); + e.sc_help_open = true; + let lines = render_sc(&e, 60, 24); + assert!(contains(&lines, "Keybindings"), "{lines:#?}"); + assert!(contains(&lines, "Navigate"), "{lines:#?}"); + assert!(contains(&lines, "Close"), "{lines:#?}"); + } + + #[test] + fn renders_without_panicking_at_minimum_size() { + // Regression guard: the migrated TextInput/Palette/Dialog primitives + // must degrade gracefully instead of panicking when the panel is + // squeezed very small (e.g. a tiny terminal or heavily split window). + let mut e = test_engine(); + e.sc_commit_message = "line one\nline two".to_string(); + e.sc_commit_input_active = true; + let _ = render_sc(&e, 10, 3); + e.sc_help_open = true; + let _ = render_sc(&e, 10, 3); + } +}