diff --git a/src/render/mouse-hit.test.ts b/src/render/mouse-hit.test.ts new file mode 100644 index 0000000..3f5eff5 --- /dev/null +++ b/src/render/mouse-hit.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "bun:test"; +import { hitTestClick } from "./mouse-hit.ts"; +import type { RepoGroup, Row } from "../types.ts"; + +function createTestGroup(name: string, repoSelected = true): RepoGroup { + return { + repoFullName: name, + repoSelected, + folded: false, + matches: [ + { + filePath: "src/test.ts", + textMatches: [], + extractSelected: false, + }, + ], + extractSelected: [false], + pickedFrom: undefined, + sectionLabel: undefined, + }; +} + +describe("hitTestClick", () => { + it("returns null for click out of bounds (y below rows)", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, 0, 100); + expect(result).toBeNull(); + }); + + it("returns null for click out of bounds (y at 0)", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, 0, 0); + expect(result).toBeNull(); + }); + + it("detects fold action on repo row arrow column", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, 0, 1); // column 0, row 1 + expect(result).toEqual({ + row: rows[0], + column: 0, + action: "fold", + }); + }); + + it("detects select action on repo row checkbox column", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, 2, 1); // column 2, row 1 + expect(result).toEqual({ + row: rows[0], + column: 2, + action: "select", + }); + }); + + it("detects navigate action on repo row elsewhere", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "repo", repoIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, 10, 1); // column 10, row 1 + expect(result).toEqual({ + row: rows[0], + column: 10, + action: "navigate", + }); + }); + + it("detects select action on extract row checkbox", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, 2, 1); // column 2, row 1 + expect(result).toEqual({ + row: rows[0], + column: 2, + action: "select", + }); + }); + + it("detects navigate action on extract row elsewhere", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [{ type: "extract", repoIndex: 0, extractIndex: 0 }]; + const result = hitTestClick(groups, rows, 0, 10, 1); // column 10, row 1 + expect(result).toEqual({ + row: rows[0], + column: 10, + action: "navigate", + }); + }); + + it("handles multiple rows and identifies the correct row", () => { + const groups = [createTestGroup("org/repoA"), createTestGroup("org/repoB")]; + const rows: Row[] = [ + { type: "repo", repoIndex: 0 }, + { type: "repo", repoIndex: 1 }, + ]; + // Row 1 is repoA, row 2 is repoB + const result = hitTestClick(groups, rows, 0, 2, 2); // column 2, row 2 + expect(result?.row.repoIndex).toBe(1); + expect(result?.action).toBe("select"); + }); + + it("handles section rows (no special action)", () => { + const groups = [createTestGroup("org/repo")]; + const rows: Row[] = [ + { type: "section", repoIndex: -1, sectionLabel: "section-a" }, + { type: "repo", repoIndex: 0 }, + ]; + const result = hitTestClick(groups, rows, 0, 0, 1); // section row + expect(result?.row.type).toBe("section"); + expect(result?.action).toBe("navigate"); + }); +}); diff --git a/src/render/mouse-hit.ts b/src/render/mouse-hit.ts new file mode 100644 index 0000000..97c6579 --- /dev/null +++ b/src/render/mouse-hit.ts @@ -0,0 +1,74 @@ +// Hit-testing for mouse clicks in the TUI viewport. +// Maps clicked (x, y) coordinates to logical Row and action type. + +import type { RepoGroup, Row } from "../types.ts"; +import { rowTerminalLines } from "./rows.ts"; + +export interface ClickTarget { + row: Row; + column: number; // Column index within the row (0-based terminal position) + action: "fold" | "select" | "navigate"; +} + +/** + * Hit-test a mouse click against the rendered rows. + * + * Returns a ClickTarget if the click lands on a valid row; null if out of bounds. + * Coordinates (x, y) are 1-indexed (terminal convention). + * + * Actions: + * - "fold" on repo rows: click lands on the ▸/▾ column (column 0) + * - "select" on any row: click lands on the ✓ checkbox column (column ~2-6 depending on line type) + * - "navigate" on any row: click elsewhere (just move cursor to that row) + */ +export function hitTestClick( + groups: RepoGroup[], + rows: Row[], + scrollOffset: number, + x: number, + y: number, +): ClickTarget | null { + // Convert 1-indexed terminal coordinates to 0-indexed row list + const clickedRowIndex = y - 1; + if (clickedRowIndex < 0 || clickedRowIndex >= rows.length) return null; + + // Calculate cumulative line heights to find which row was clicked + let lineOffset = 0; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const group = groups[row.repoIndex] ?? undefined; + const h = rowTerminalLines(group, row); + + if (lineOffset <= clickedRowIndex && clickedRowIndex < lineOffset + h) { + // This row was clicked + // Column 0 is the fold arrow (for repo rows) + // Column ~2 is the checkbox (after "▸ " or " ") + // For extract rows, the checkbox offset is slightly different based on line type + + let action: "fold" | "select" | "navigate" = "navigate"; + + if (row.type === "repo") { + // Repo row layout: "▸ ✓ repo-name" + // Arrow at column 0, checkbox at column 2 (after "▸ ") + if (x === 0) { + action = "fold"; + } else if (x === 2) { + action = "select"; + } + } else if (row.type === "extract") { + // Extract row layout: " ✓ path:line:col" + // Checkbox at column 2 (after " ") + if (x === 2) { + action = "select"; + } + } + // Section rows don't support any action (just navigate) + + return { row, column: x, action }; + } + + lineOffset += h; + } + + return null; +} diff --git a/src/tui.ts b/src/tui.ts index b2bf801..9260513 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -20,6 +20,7 @@ import { rebuildTeamSections, } from "./group.ts"; import { parseMouseEvent } from "./render/mouse.ts"; +import { hitTestClick } from "./render/mouse-hit.ts"; import type { FilterTarget, OutputFormat, OutputType, RepoGroup, Row } from "./types.ts"; // ─── Key binding constants ──────────────────────────────────────────────────── @@ -291,7 +292,36 @@ export async function runInteractive( // Try parsing as a mouse event; if it's not a mouse event, treat as keyboard input const mouseEvent = parseMouseEvent(key); if (mouseEvent !== null) { - // Mouse events are silently ignored for now; this hook is here for future #170 / #168 + // Hit-test the click against visible rows + const target = hitTestClick(groups, rows, scrollOffset, mouseEvent.x, mouseEvent.y); + if (target !== null) { + const row = target.row; + if (target.action === "fold" && row.type === "repo") { + // Toggle fold for this repo + const group = groups[row.repoIndex]; + group.folded = !group.folded; + redraw(); + } else if (target.action === "select") { + if (row.type === "repo") { + // Toggle repo selection + const group = groups[row.repoIndex]; + group.repoSelected = !group.repoSelected; + } else if (row.type === "extract" && row.extractIndex !== undefined) { + // Toggle extract selection + const group = groups[row.repoIndex]; + group.extractSelected[row.extractIndex] = !group.extractSelected[row.extractIndex]; + } + redraw(); + } else if (target.action === "navigate") { + // Move cursor to this row + const rowIndex = rows.findIndex((r) => r === row); + if (rowIndex >= 0) { + cursor = rowIndex; + scrollOffset = Math.min(scrollOffset, cursor); + redraw(); + } + } + } continue; }