Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/render/mouse-hit.test.ts
Original file line number Diff line number Diff line change
@@ -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,
},
],
Comment thread
shouze marked this conversation as resolved.
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");
});
});
74 changes: 74 additions & 0 deletions src/render/mouse-hit.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
shouze marked this conversation as resolved.

// 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;
}
32 changes: 31 additions & 1 deletion src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Comment thread
shouze marked this conversation as resolved.
Comment thread
shouze marked this conversation as resolved.
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];
}
Comment thread
shouze marked this conversation as resolved.
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();
}
Comment thread
shouze marked this conversation as resolved.
}
}
continue;
}

Expand Down
Loading