| 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 | |
| 11 | export 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. */ |
| 19 | export type Zoom = 'none' | 'chat'; |
| 20 | |
| 21 | export 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 | |
| 44 | export 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. */ |
| 59 | const FRAME = 2; |
| 60 | /** The status box: border, one row of content, border. */ |
| 61 | const STATUS_HEIGHT = 3; |
| 62 | /** The help line under everything. */ |
| 63 | const HELP_HEIGHT = 1; |
| 64 | |
| 65 | export 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 | |
| 141 | export 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 | */ |
| 152 | export 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 | } |