anvilsign in

collin/strudel-claude

1// Where the panes are on screen.
2//
3// This exists because a click is a pair of numbers and nothing else — turning it
4// into "line 4, column 12 of the editor" needs the exact geometry the renderer
5// used. Deriving that geometry twice, once in the layout and once in the hit
6// test, is how clicks end up one row off. So it's computed here, once, and both
7// the renderer and the mouse handler read the same numbers.
8//
9// Rows and columns are zero-based screen coordinates.
10
11export interface Rect {
12 top: number;
13 left: number;
14 width: number;
15 height: number;
16}
17
18/** Which pane, if any, is expanded to fill the window. */
19export type Zoom = 'none' | 'chat';
20
21export interface Layout {
22 wide: boolean;
23 zoom: Zoom;
24 /** Text area of each pane — inside its border and padding. */
25 sessions: Rect | null;
26 editor: Rect;
27 chat: Rect | null;
28 /**
29 * The panes including their borders and padding, for click-to-focus.
30 *
31 * Focus and content need different rectangles. Which line you clicked has to
32 * be measured against the text area or it lands a row off, but "did you click
33 * this pane" should count the frame too — the border and the padding column
34 * are visibly part of the box, and a click there that does nothing reads as
35 * the mouse being broken rather than as a two-column miss.
36 */
37 sessionsFrame: Rect | null;
38 editorFrame: Rect;
39 chatFrame: Rect | null;
40 /** Width of the editor's line-number gutter, including its trailing space. */
41 gutter: number;
42}
43
44export interface LayoutInput {
45 rows: number;
46 columns: number;
47 sessionsWidth: number;
48 chatWidth: number;
49 /** Rows the log occupies including its border, or 0 when it is closed. */
50 logRows: number;
51 /** Show the side panels only above this width. */
52 wideEnough: number;
53 /** Line count, which sets how wide the gutter has to be. */
54 lineCount: number;
55 zoom: Zoom;
56}
57
58/** Border (1) + horizontal padding (1) on each side of a bordered box. */
59const FRAME = 2;
60/** The status box: border, one row of content, border. */
61const STATUS_HEIGHT = 3;
62/** The help line under everything. */
63const HELP_HEIGHT = 1;
64
65export function computeLayout(input: LayoutInput): Layout {
66 const {
67 rows, columns, sessionsWidth, chatWidth, logRows, wideEnough, lineCount, zoom,
68 } = input;
69 const wide = columns >= wideEnough;
70
71 // The panes start at the top of the screen; the status bar, the log (when
72 // open) and the help line stack below them. Reading order follows where your
73 // eyes already are — you look at the code, and the transport state is one
74 // glance down rather than back up past it.
75 const bodyTop = 0;
76
77 // Zoomed, the chat takes the whole body and the log goes with the rest: a
78 // 34-column pane is fine for "added more sparkle" and hopeless for reading a
79 // long explanation back, which is the whole reason to zoom.
80 if (zoom === 'chat') {
81 // The 2 is the zoomed chat box's own border, top and bottom.
82 const height = Math.max(1, rows - STATUS_HEIGHT - HELP_HEIGHT - 2);
83 const editor = { top: bodyTop, left: 1, width: Math.max(1, columns - 2), height: 0 };
84 return {
85 wide,
86 zoom,
87 sessions: null,
88 sessionsFrame: null,
89 editor,
90 editorFrame: editor,
91 chat: { top: bodyTop + 1, left: FRAME, width: Math.max(1, columns - FRAME * 2), height },
92 chatFrame: { top: bodyTop, left: 0, width: columns, height: height + 2 },
93 gutter: String(Math.max(1, lineCount)).length + 1,
94 };
95 }
96
97 const bodyHeight = Math.max(1, rows - STATUS_HEIGHT - logRows - HELP_HEIGHT);
98
99 const sessions: Rect | null = wide
100 ? { top: bodyTop + 1, left: FRAME, width: sessionsWidth - FRAME * 2, height: bodyHeight - 2 }
101 : null;
102
103 const editorLeft = wide ? sessionsWidth : 0;
104 const editorWidth = columns - editorLeft - (wide ? chatWidth : 0);
105
106 const chat: Rect | null = wide
107 ? {
108 top: bodyTop + 1,
109 left: columns - chatWidth + FRAME,
110 width: chatWidth - FRAME * 2,
111 height: bodyHeight - 2,
112 }
113 : null;
114
115 // The editor is not in a bordered box — only horizontal padding — so it is
116 // one row taller than its neighbours and starts at the top of the body.
117 const editor: Rect = {
118 top: bodyTop,
119 left: editorLeft + 1,
120 width: Math.max(1, editorWidth - 2),
121 height: bodyHeight,
122 };
123
124 return {
125 wide,
126 zoom,
127 sessions,
128 chat,
129 editor,
130 sessionsFrame: wide
131 ? { top: bodyTop, left: 0, width: sessionsWidth, height: bodyHeight }
132 : null,
133 chatFrame: wide
134 ? { top: bodyTop, left: columns - chatWidth, width: chatWidth, height: bodyHeight }
135 : null,
136 editorFrame: { top: bodyTop, left: editorLeft, width: Math.max(1, editorWidth), height: bodyHeight },
137 gutter: String(Math.max(1, lineCount)).length + 1,
138 };
139}
140
141export const contains = (rect: Rect | null, row: number, column: number): boolean =>
142 rect !== null
143 && row >= rect.top && row < rect.top + rect.height
144 && column >= rect.left && column < rect.left + rect.width;
145
146/**
147 * Which buffer position a click landed on.
148 *
149 * `scrollTop` is the first visible line — the editor scrolls to follow the
150 * cursor, so a click on the third visible row is not line three.
151 */
152export function editorPosition(
153 layout: Layout,
154 scrollTop: number,
155 row: number,
156 column: number,
157): { row: number; col: number } {
158 const line = scrollTop + (row - layout.editor.top);
159 const col = column - layout.editor.left - layout.gutter;
160 return { row: Math.max(0, line), col: Math.max(0, col) };
161}