Skip to content

Commit 91d49d4

Browse files
committed
feat(opentui): add elbow connector tool
1 parent 89e0769 commit 91d49d4

12 files changed

Lines changed: 229 additions & 45 deletions

File tree

packages/opentui/src/app.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -458,13 +458,14 @@ export function buildHelpText(binaryName = "termdraw"): string {
458458
return truncateToCells(
459459
`${binaryName} [--load file.td.json|-] [--output file] [--fenced|--plain]\n\n` +
460460
`Controls:\n` +
461-
` right palette click Select / Box / Line / Brush / Text, box styles, and colors\n` +
462-
` Ctrl+T / Tab cycle select / box / line / brush / text\n` +
463-
` B / A / U / P / T switch to Brush / Select / Box / Line / Text outside text entry\n` +
461+
` right palette click Select / Box / Line / Elbow / Brush / Text, box styles, and colors\n` +
462+
` Ctrl+T / Tab cycle select / box / line / elbow / brush / text\n` +
463+
` B / A / U / P / E / T switch to Brush / Select / Box / Line / Elbow / Text outside text entry\n` +
464464
` select tool click to select, drag empty space to marquee-select multiple objects\n` +
465465
` click objects select and move them\n` +
466466
` drag handles resize boxes / adjust line endpoints\n` +
467467
` line tool choose Smooth (Braille-aware), Single, or Double line stencils\n` +
468+
` elbow tool create right-angle connectors with arrowheads using line stencils\n` +
468469
` text tool choose No border, Single, Double, or Dashed textbox borders\n` +
469470
` Shift + drag constrain line creation/editing to horizontal or vertical\n` +
470471
` selected text shows a virtual selection box\n` +
@@ -473,8 +474,8 @@ export function buildHelpText(binaryName = "termdraw"): string {
473474
` Ctrl+Q quit\n` +
474475
` Ctrl+Z / Ctrl+Y undo / redo\n` +
475476
` Ctrl+X clear canvas\n` +
476-
` [ / ] cycle box style in Box mode, line style in Line mode, text border in Text mode, or brush in Brush mode\n` +
477-
` mouse wheel cycle box style in Box mode, line style in Line mode, or brush in Brush mode\n` +
477+
` [ / ] cycle box style in Box mode, line style in Line/Elbow mode, text border in Text mode, or brush in Brush mode\n` +
478+
` mouse wheel cycle box style in Box mode, line style in Line/Elbow mode, or brush in Brush mode\n` +
478479
` brush tool choose from preset brush stencils in the palette\n` +
479480
` Space stamp a line point or current brush / insert space in Text mode\n` +
480481
` Enter / Ctrl+S export art\n` +

packages/opentui/src/app/input.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ test("handleKeyPress invokes save on Ctrl+S", () => {
156156
test("handleKeyPress switches tools with hotkeys outside text entry", () => {
157157
let mode: string | null = null;
158158
let renders = 0;
159-
const { event, wasPrevented } = createKeyEvent("b", { raw: "b" });
159+
const { event, wasPrevented } = createKeyEvent("e", { raw: "e" });
160160

161161
const handled = handleKeyPress({
162162
key: event as never,
@@ -178,7 +178,7 @@ test("handleKeyPress switches tools with hotkeys outside text entry", () => {
178178

179179
expect(handled).toBe(true);
180180
expect(wasPrevented()).toBe(true);
181-
expect(mode === "paint").toBe(true);
181+
expect(mode === "elbow").toBe(true);
182182
expect(renders).toBe(1);
183183
});
184184

@@ -217,7 +217,7 @@ test("handleKeyPress cycles line styles with bracket keys", () => {
217217
const { event: leftKey } = createKeyEvent("[", { raw: "[" });
218218
const { event: rightKey } = createKeyEvent("]", { raw: "]" });
219219
const state = createMockState({
220-
currentMode: "line",
220+
currentMode: "elbow",
221221
cycleLineStyle: (delta: number) => {
222222
cycles.push(delta);
223223
},

packages/opentui/src/app/input.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ function applyStyleButtonSelection(state: DrawState, style: string): void {
5151
return;
5252
}
5353

54-
if (state.currentMode === "line") {
55-
state.setMode("line");
54+
if (state.currentMode === "line" || state.currentMode === "elbow") {
55+
state.setMode(state.currentMode);
5656
state.setLineStyle(style as LineStyle);
5757
return;
5858
}
@@ -312,7 +312,7 @@ export function handleKeyPress(
312312
}
313313
}
314314

315-
if (state.currentMode === "line") {
315+
if (state.currentMode === "line" || state.currentMode === "elbow") {
316316
if (key.raw === "[") {
317317
key.preventDefault();
318318
state.cycleLineStyle(-1);

packages/opentui/src/app/layout.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export function isInsideRect(
6161
/** Returns the number of style rows rendered beneath the active tool. */
6262
export function getContextualStyleRowCount(currentMode: DrawMode): number {
6363
if (currentMode === "box") return BOX_STYLE_OPTIONS.length;
64-
if (currentMode === "line") return LINE_STYLE_OPTIONS.length;
64+
if (currentMode === "line" || currentMode === "elbow") return LINE_STYLE_OPTIONS.length;
6565
if (currentMode === "paint") return BRUSH_OPTIONS.length;
6666
if (currentMode === "text") return TEXT_BORDER_OPTIONS.length;
6767
return 0;
@@ -79,6 +79,7 @@ export function getToolButtons(layout: AppLayout, currentMode: DrawMode): ToolBu
7979
{ mode: "select", icon: "◎", label: "Select", color: COLORS.select },
8080
{ mode: "box", icon: "▣", label: "Box", color: COLORS.warning },
8181
{ mode: "line", icon: "╱", label: "Line", color: COLORS.accent },
82+
{ mode: "elbow", icon: "└", label: "Elbow", color: COLORS.accent },
8283
{ mode: "paint", icon: "▒", label: "Brush", color: COLORS.paint },
8384
{ mode: "text", icon: "T", label: "Text", color: COLORS.success },
8485
];
@@ -109,6 +110,7 @@ export function getContextualStyleButtons(layout: AppLayout, currentMode: DrawMo
109110
if (
110111
currentMode !== "box" &&
111112
currentMode !== "line" &&
113+
currentMode !== "elbow" &&
112114
currentMode !== "paint" &&
113115
currentMode !== "text"
114116
) {
@@ -124,7 +126,7 @@ export function getContextualStyleButtons(layout: AppLayout, currentMode: DrawMo
124126
const options =
125127
currentMode === "box"
126128
? BOX_STYLE_OPTIONS
127-
: currentMode === "line"
129+
: currentMode === "line" || currentMode === "elbow"
128130
? LINE_STYLE_OPTIONS
129131
: currentMode === "text"
130132
? TEXT_BORDER_OPTIONS

packages/opentui/src/app/render.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ function drawHeaderRow(
123123
const modeColor =
124124
state.currentMode === "select"
125125
? COLORS.select
126-
: state.currentMode === "line"
126+
: state.currentMode === "line" || state.currentMode === "elbow"
127127
? COLORS.accent
128128
: state.currentMode === "box"
129129
? COLORS.warning
@@ -156,7 +156,7 @@ function drawHeaderRow(
156156
COLORS.warning,
157157
COLORS.panel,
158158
);
159-
} else if (state.currentMode === "line") {
159+
} else if (state.currentMode === "line" || state.currentMode === "elbow") {
160160
const lineStyle =
161161
LINE_STYLE_OPTIONS.find((option) => option.style === state.currentLineStyle) ??
162162
LINE_STYLE_OPTIONS[0]!;
@@ -228,7 +228,7 @@ function drawFooterRow(
228228
): void {
229229
const text =
230230
footerTextOverride ??
231-
`B Brush • A Select • U Box • P Line • T Text • Esc Deselect • Enter/Ctrl+S Export Art${
231+
`B Brush • A Select • U Box • P Line • E Elbow • T Text • Esc Deselect • Enter/Ctrl+S Export Art${
232232
canSaveDiagram ? " • Ctrl+D Save Diagram" : ""
233233
} • Ctrl+Q Quit`;
234234
const combined = `${text} ${status}`;
@@ -321,7 +321,7 @@ function drawStyleButton(
321321
const isActive =
322322
state.currentMode === "box"
323323
? state.currentBoxStyle === button.style
324-
: state.currentMode === "line"
324+
: state.currentMode === "line" || state.currentMode === "elbow"
325325
? state.currentLineStyle === button.style
326326
: state.currentMode === "text"
327327
? state.currentTextBorderMode === button.style

packages/opentui/src/app/theme.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const INK_COLOR_VALUES: Record<InkColor, RGBA> = {
113113
export const TOOL_HOTKEYS: Partial<Record<string, DrawMode>> = {
114114
a: "select",
115115
b: "paint",
116+
e: "elbow",
116117
p: "line",
117118
t: "text",
118119
u: "box",

packages/opentui/src/draw-state.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,31 @@ describe("DrawState", () => {
110110
expect(state.getCompositeCell(12, 6)).toBe(" ");
111111
});
112112

113+
test("elbow tool keeps a live orthogonal preview and commits with an arrowhead", () => {
114+
const state = new DrawState(24, 16);
115+
state.setMode("elbow");
116+
117+
const start = canvasPoint(state, 1, 1);
118+
const end = canvasPoint(state, 6, 4);
119+
state.handlePointerEvent({ type: "down", button: MouseButton.LEFT, ...start });
120+
state.handlePointerEvent({ type: "drag", button: MouseButton.LEFT, ...end });
121+
122+
const preview = state.getActivePreviewCharacters();
123+
expect(preview.get("1,1")).toBe("─");
124+
expect(preview.get("5,1")).toBe("─");
125+
expect(preview.get("6,1")).toBe("┌");
126+
expect(preview.get("6,2")).toBe("│");
127+
expect(preview.get("6,4")).toBe("v");
128+
129+
state.handlePointerEvent({ type: "up", button: MouseButton.LEFT, ...end });
130+
131+
expect(state.getCompositeCell(1, 1)).toBe("─");
132+
expect(state.getCompositeCell(5, 1)).toBe("─");
133+
expect(state.getCompositeCell(6, 1)).toBe("┌");
134+
expect(state.getCompositeCell(6, 2)).toBe("│");
135+
expect(state.getCompositeCell(6, 4)).toBe("v");
136+
});
137+
113138
test("clicking empty space in line mode does not create a one-cell line", () => {
114139
const state = new DrawState(20, 10);
115140
state.setMode("box");

0 commit comments

Comments
 (0)