acrylblends 1.0.8 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,11 @@ interface PageStream {
9
9
  interface ChannelOptions {
10
10
  /** How long a page has to acknowledge a read-only call (`look`, `wait`) before the next page is tried. */
11
11
  readonly ackMs?: number;
12
+ /**
13
+ * How long a read-only call waits for a page to register when there is none, or when every page was silent and a new one may be on its way (a page that is moving to another
14
+ * address registers again a moment later). 0 (the default) fails at once.
15
+ */
16
+ readonly attachWaitMs?: number;
12
17
  }
13
18
  /**
14
19
  * The pages and who drives which. One page answers a call, chosen the same way every time: the page the Host just sent somewhere (it comes back as a new page, `prefer`), else the
@@ -16,6 +21,7 @@ interface ChannelOptions {
16
21
  */
17
22
  declare class UiChannel {
18
23
  private readonly ackMs;
24
+ private readonly attachWaitMs;
19
25
  /** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
20
26
  private preferred;
21
27
  constructor(options?: ChannelOptions);
@@ -52,7 +58,7 @@ declare class UiChannel {
52
58
  signal?: AbortSignal;
53
59
  timeoutMs?: number;
54
60
  window?: number;
55
- }): Promise<UiResult>;
61
+ }, retried?: boolean): Promise<UiResult>;
56
62
  private callOne;
57
63
  /**
58
64
  * A promise for the next time a page connects (a page that reloaded or moved opens a new stream). Ask for it BEFORE the call that makes the page leave, or a fast page is missed.
@@ -1,5 +1,5 @@
1
1
  import { f as UiActionResult, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
2
- import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-BD8_2pkh.js";
2
+ import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-Dw-siYTp.js";
3
3
  import { Context } from "@deepseek-ai/cordis";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
 
@@ -37,6 +37,9 @@ interface ShellWindow {
37
37
  readonly focused: boolean;
38
38
  readonly width: number;
39
39
  readonly height: number;
40
+ /** Where the window's top left corner is on the screen (so two windows on top of each other can be told). */
41
+ readonly x?: number;
42
+ readonly y?: number;
40
43
  }
41
44
  interface ShellMenuItem {
42
45
  /** Labels from the top of the menu, joined with `>`: `File>New Note`. */
@@ -4,7 +4,7 @@ import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
4
4
  import "../helpers-BEjcdbgw.js";
5
5
  import { n as clientModules } from "../client-modules-drj4mjhj.js";
6
6
  import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
7
- import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-m13Pk-AI.js";
7
+ import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-CBi3EMnj.js";
8
8
  import "../src-BTBm4OPD.js";
9
9
  import "../middleware-BfG_fp8P.js";
10
10
  import { isAbsolute, join, relative, resolve } from "node:path";
@@ -600,7 +600,7 @@ var DoorError = class extends Error {
600
600
  };
601
601
  /** A shell answer as text an agent reads: windows and menu items one to a line. */
602
602
  function renderShell(result) {
603
- if ("windows" in result) return result.windows.length === 0 ? "no windows are open" : result.windows.map((window) => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.focused ? " focused" : ""}`).join("\n");
603
+ if ("windows" in result) return result.windows.length === 0 ? "no windows are open" : result.windows.map((window) => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.x === void 0 || window.y === void 0 ? "" : `@${String(window.x)},${String(window.y)}`}${window.focused ? " focused" : ""}`).join("\n");
604
604
  if ("items" in result) return result.items.length === 0 ? "the app has no menu" : result.items.map((item) => `${item.path}${item.enabled ? "" : " (disabled)"}${item.ends === true ? " (ends the app; needs force)" : ""}`).join("\n");
605
605
  return result.detail;
606
606
  }
@@ -1380,7 +1380,7 @@ function apply(ctx, rawConfig = {}) {
1380
1380
  ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`);
1381
1381
  };
1382
1382
  const secret = newSecret();
1383
- const channel = new UiChannel();
1383
+ const channel = new UiChannel({ attachWaitMs: 2e3 });
1384
1384
  const shell = new ShellChannel();
1385
1385
  const door = new Door({
1386
1386
  channel,
@@ -1,6 +1,6 @@
1
1
  import { t as Acryl } from "../app-D8riatvv.js";
2
2
  import { n as AuditEntry, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
3
- import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-BD8_2pkh.js";
3
+ import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-Dw-siYTp.js";
4
4
 
5
5
  //#region ../../plugins/acryl-door/src/host/page-routes.d.ts
6
6
  /** How often the Host writes a `ping`; the page gives up on a stream that is silent for three of these. */
package/lib/door/host.js CHANGED
@@ -1,2 +1,2 @@
1
- import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-m13Pk-AI.js";
1
+ import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-CBi3EMnj.js";
2
2
  export { AuditLog, HEARTBEAT_MS, UiChannel, auditPath, pageChannelRoutes, renderSnapshot };
@@ -34,10 +34,12 @@ const DEFAULT_ACK_MS = 3e3;
34
34
  */
35
35
  var UiChannel = class {
36
36
  ackMs;
37
+ attachWaitMs;
37
38
  /** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
38
39
  preferred;
39
40
  constructor(options = {}) {
40
41
  this.ackMs = options.ackMs ?? DEFAULT_ACK_MS;
42
+ this.attachWaitMs = options.attachWaitMs ?? 0;
41
43
  }
42
44
  connections = /* @__PURE__ */ new Map();
43
45
  pending = /* @__PURE__ */ new Map();
@@ -114,8 +116,10 @@ var UiChannel = class {
114
116
  * until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
115
117
  * @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
116
118
  */
117
- async call(request, options = {}) {
119
+ async call(request, options = {}, retried = false) {
118
120
  if (this.closed) throw new UiControlError("unloaded", "the door was unloaded");
121
+ const readOnlyCall = request.op === "snapshot" || request.op === "wait";
122
+ if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs);
119
123
  const ordered = this.ordered();
120
124
  const candidates = options.window === void 0 ? ordered : ordered.filter((connection) => connection.shellWindow === options.window);
121
125
  if (candidates.length === 0 && options.window !== void 0) {
@@ -124,13 +128,21 @@ var UiChannel = class {
124
128
  }
125
129
  if (candidates.length === 0) throw new UiControlError("no-window", "no page of the app is open to control");
126
130
  if (options.signal?.aborted === true) throw new UiControlError("aborted", "the call was cancelled");
127
- const readOnly = request.op === "snapshot" || request.op === "wait";
128
- for (const target of candidates) try {
129
- return await this.callOne(target, request, options, readOnly);
130
- } catch (cause) {
131
- if (!(cause instanceof Silent)) throw cause;
131
+ const readOnly = readOnlyCall;
132
+ const tried = /* @__PURE__ */ new Set();
133
+ for (const target of candidates) {
134
+ tried.add(target.windowId);
135
+ try {
136
+ return await this.callOne(target, request, options, readOnly);
137
+ } catch (cause) {
138
+ if (!(cause instanceof Silent)) throw cause;
139
+ }
140
+ }
141
+ if (readOnly && !retried && this.attachWaitMs > 0 && options.window === void 0) {
142
+ const fresh = await this.expectNewPage(this.attachWaitMs);
143
+ if (fresh !== void 0 && !tried.has(fresh)) return await this.call(request, options, true);
132
144
  }
133
- throw new UiControlError("timeout", `no page of the app answered within ${String(Math.round(this.ackMs / 1e3))} s: the app's pages are open but none responded (a tab in the background, or a page that is still loading). Bring the app's tab to the front, or navigate it, then look again`);
145
+ throw new UiControlError("timeout", `no page of the app answered within ${String(Math.round(this.ackMs / 1e3))} s: the app's pages are open but none responded. Likely causes: a tab in the background, a page that is still loading after a navigation (look again), or a page that holds the browser's six connections with event streams of its own (a page should keep to two). Bring the app's tab to the front, or navigate it, then look again`);
134
146
  }
135
147
  callOne(target, request, options, failOver) {
136
148
  const signal = options.signal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -57,6 +57,11 @@ const DEFAULT_ACK_MS = 3000
57
57
  export interface ChannelOptions {
58
58
  /** How long a page has to acknowledge a read-only call (`look`, `wait`) before the next page is tried. */
59
59
  readonly ackMs?: number
60
+ /**
61
+ * How long a read-only call waits for a page to register when there is none, or when every page was silent and a new one may be on its way (a page that is moving to another
62
+ * address registers again a moment later). 0 (the default) fails at once.
63
+ */
64
+ readonly attachWaitMs?: number
60
65
  }
61
66
 
62
67
  /**
@@ -65,11 +70,13 @@ export interface ChannelOptions {
65
70
  */
66
71
  export class UiChannel {
67
72
  private readonly ackMs: number
73
+ private readonly attachWaitMs: number
68
74
  /** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
69
75
  private preferred: string | undefined
70
76
 
71
77
  constructor(options: ChannelOptions = {}) {
72
78
  this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
79
+ this.attachWaitMs = options.attachWaitMs ?? 0
73
80
  }
74
81
 
75
82
  private readonly connections = new Map<string, Connection>()
@@ -143,8 +150,11 @@ export class UiChannel {
143
150
  * until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
144
151
  * @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
145
152
  */
146
- async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}): Promise<UiResult> {
153
+ async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}, retried = false): Promise<UiResult> {
147
154
  if (this.closed) throw new UiControlError('unloaded', 'the door was unloaded')
155
+ const readOnlyCall = request.op === 'snapshot' || request.op === 'wait'
156
+ // A page that is moving to another address registers again a moment later: a read-only call right after a navigate or reload waits for it, rather than failing.
157
+ if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs)
148
158
  const ordered = this.ordered()
149
159
  // `window` is the number `window list` prints: that desktop window's page, not the one used last.
150
160
  const candidates = options.window === undefined ? ordered : ordered.filter(connection => connection.shellWindow === options.window)
@@ -154,15 +164,22 @@ export class UiChannel {
154
164
  }
155
165
  if (candidates.length === 0) throw new UiControlError('no-window', 'no page of the app is open to control')
156
166
  if (options.signal?.aborted === true) throw new UiControlError('aborted', 'the call was cancelled')
157
- const readOnly = request.op === 'snapshot' || request.op === 'wait'
167
+ const readOnly = readOnlyCall
168
+ const tried = new Set<string>()
158
169
  for (const target of candidates) {
170
+ tried.add(target.windowId)
159
171
  try {
160
172
  return await this.callOne(target, request, options, readOnly)
161
173
  } catch (cause) {
162
174
  if (!(cause instanceof Silent)) throw cause
163
175
  }
164
176
  }
165
- throw new UiControlError('timeout', `no page of the app answered within ${String(Math.round(this.ackMs / 1000))} s: the app's pages are open but none responded (a tab in the background, or a page that is still loading). Bring the app's tab to the front, or navigate it, then look again`)
177
+ // Every page was silent. A page that registered meanwhile (the one that was moving) gets the call once.
178
+ if (readOnly && !retried && this.attachWaitMs > 0 && options.window === undefined) {
179
+ const fresh = await this.expectNewPage(this.attachWaitMs)
180
+ if (fresh !== undefined && !tried.has(fresh)) return await this.call(request, options, true)
181
+ }
182
+ throw new UiControlError('timeout', `no page of the app answered within ${String(Math.round(this.ackMs / 1000))} s: the app's pages are open but none responded. Likely causes: a tab in the background, a page that is still loading after a navigation (look again), or a page that holds the browser's six connections with event streams of its own (a page should keep to two). Bring the app's tab to the front, or navigate it, then look again`)
166
183
  }
167
184
 
168
185
  private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
@@ -71,7 +71,7 @@ function renderShell(result: ShellResult): string {
71
71
  if ('windows' in result) {
72
72
  return result.windows.length === 0
73
73
  ? 'no windows are open'
74
- : result.windows.map(window => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.focused ? ' focused' : ''}`).join('\n')
74
+ : result.windows.map(window => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.x === undefined || window.y === undefined ? '' : `@${String(window.x)},${String(window.y)}`}${window.focused ? ' focused' : ''}`).join('\n')
75
75
  }
76
76
  if ('items' in result) return result.items.length === 0 ? 'the app has no menu' : result.items.map(item => `${item.path}${item.enabled ? '' : ' (disabled)'}${item.ends === true ? ' (ends the app; needs force)' : ''}`).join('\n')
77
77
  return result.detail
@@ -115,7 +115,8 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
115
115
  ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`)
116
116
  }
117
117
  const secret = newSecret()
118
- const channel = new UiChannel()
118
+ // A look right after a navigate or reload waits up to two seconds for the page that is moving to register again, and a read-only call that no page acknowledged gives a page that just registered one more try.
119
+ const channel = new UiChannel({ attachWaitMs: 2000 })
119
120
  const shell = new ShellChannel()
120
121
  const audit = new AuditLog(auditPath(stateDir(app.root)))
121
122
  const door = new Door({
@@ -27,6 +27,9 @@ export interface ShellWindow {
27
27
  readonly focused: boolean
28
28
  readonly width: number
29
29
  readonly height: number
30
+ /** Where the window's top left corner is on the screen (so two windows on top of each other can be told). */
31
+ readonly x?: number
32
+ readonly y?: number
30
33
  }
31
34
 
32
35
  export interface ShellMenuItem {
@@ -316,25 +316,38 @@ async function start(): Promise<void> {
316
316
  */
317
317
  const watch = async (source: RunSource, own: boolean): Promise<void> => {
318
318
  let live = true
319
- following = true
320
- send.disabled = true
321
- stop.disabled = false
322
319
  let changed = false
323
320
  let answer: HTMLElement | undefined
324
321
  let answered = ''
325
- // A coding agent started headless says little until it is done, so the panel says it is working, and for how long.
326
- const started = Date.now()
327
- const working = line('', 'working')
322
+ // A coding agent started headless says little until it is done, so the panel says it is working, and for how long (from when the run began, not from when this page loaded).
323
+ let started = Date.now()
324
+ let working: HTMLElement | undefined
325
+ let timer: ReturnType<typeof setInterval> | undefined
328
326
  const tick = (): void => {
329
- const seconds = Math.floor((Date.now() - started) / 1000)
327
+ if (working === undefined) return
328
+ const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000))
330
329
  working.textContent = `Working... ${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`
331
330
  log.append(working)
332
331
  }
333
- tick()
334
- const timer = setInterval(tick, 1000)
332
+ // The panel is busy once there is a run to show. A page that just loaded asks the server for the latest run first, and "there is none" must not make a message typed in that moment
333
+ // wait or vanish, so a run that is only being looked for does not count (it begins at its first event).
334
+ const begin = (): void => {
335
+ if (working !== undefined) return
336
+ following = true
337
+ send.disabled = true
338
+ stop.disabled = false
339
+ working = line('', 'working')
340
+ tick()
341
+ timer = setInterval(tick, 1000)
342
+ }
343
+ if (own) begin()
335
344
  try {
336
345
  for await (const { event, data } of source.events) {
346
+ if (event === 'idle') continue
347
+ begin()
337
348
  if (event === 'run') {
349
+ if (typeof data.startedAt === 'number') started = data.startedAt
350
+ tick()
338
351
  live = data.ended !== true
339
352
  if (!own) {
340
353
  if (typeof data.message === 'string') line(data.message, 'me')
@@ -365,11 +378,13 @@ async function start(): Promise<void> {
365
378
  } catch (error) {
366
379
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
367
380
  } finally {
368
- clearInterval(timer)
369
- working.remove()
370
- following = false
371
- send.disabled = false
372
- stop.disabled = true
381
+ if (timer !== undefined) clearInterval(timer)
382
+ if (working !== undefined) {
383
+ working.remove()
384
+ following = false
385
+ send.disabled = false
386
+ stop.disabled = true
387
+ }
373
388
  }
374
389
  // A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
375
390
  // A run that ended before this page loaded was already shown after its reload, and does not reload it again.
@@ -175,6 +175,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
175
175
  readonly id: number
176
176
  readonly driver: string
177
177
  readonly message: string
178
+ readonly startedAt: number
178
179
  readonly events: Array<{ event: string; data: unknown }>
179
180
  readonly followers: Set<(item: { event: string; data: unknown }) => void>
180
181
  readonly cancel: AbortController
@@ -189,6 +190,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
189
190
  id: runs,
190
191
  driver: driverName,
191
192
  message,
193
+ startedAt: Date.now(),
192
194
  events: [],
193
195
  followers: new Set(),
194
196
  cancel: new AbortController(),
@@ -241,6 +243,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
241
243
  id: run.id,
242
244
  driver: run.driver,
243
245
  message: run.message,
246
+ startedAt: run.startedAt,
244
247
  ended: run.ended,
245
248
  endedAt: run.endedAt ?? null,
246
249
  },
@@ -17,6 +17,7 @@ export interface WindowPort {
17
17
  isFocused(): boolean
18
18
  isMinimized(): boolean
19
19
  getSize(): number[]
20
+ getPosition(): number[]
20
21
  restore(): void
21
22
  focus(): void
22
23
  close(): void
@@ -68,6 +69,8 @@ export type ShellResult =
68
69
  focused: boolean
69
70
  width: number
70
71
  height: number
72
+ x?: number
73
+ y?: number
71
74
  }[]
72
75
  }
73
76
  | { readonly items: readonly { path: string; label: string; enabled: boolean; ends?: boolean }[] }
@@ -133,6 +136,7 @@ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): Sh
133
136
  return {
134
137
  windows: desktop.windows().map((window) => {
135
138
  const [width = 0, height = 0] = window.getSize()
139
+ const [x = 0, y = 0] = window.getPosition()
136
140
  return {
137
141
  id: window.id,
138
142
  title: window.getTitle(),
@@ -140,6 +144,8 @@ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): Sh
140
144
  focused: window.isFocused(),
141
145
  width,
142
146
  height,
147
+ x,
148
+ y,
143
149
  }
144
150
  }),
145
151
  }
@@ -99,10 +99,17 @@ if (!app.requestSingleInstanceLock()) {
99
99
  const where = (() => {
100
100
  if (beside === undefined) return {}
101
101
  const bounds = beside.getBounds()
102
- return cascade(bounds, screen.getDisplayMatching(bounds).workArea, {
103
- width: 1100,
104
- height: 760,
105
- })
102
+ // The places of the windows that are open: a new window never goes exactly where one already is.
103
+ const taken = BrowserWindow.getAllWindows()
104
+ .filter((other) => !other.isDestroyed())
105
+ .map((other) => other.getPosition())
106
+ .map(([x = 0, y = 0]) => ({ x, y }))
107
+ return cascade(
108
+ bounds,
109
+ screen.getDisplayMatching(bounds).workArea,
110
+ { width: 1100, height: 760 },
111
+ taken,
112
+ )
106
113
  })()
107
114
  const created = new BrowserWindow(windowOptions({ title: chosen.name, ...where }))
108
115
  created.webContents.setUserAgent(
@@ -136,6 +143,7 @@ if (!app.requestSingleInstanceLock()) {
136
143
  isFocused: () => candidate.isFocused(),
137
144
  isMinimized: () => candidate.isMinimized(),
138
145
  getSize: () => candidate.getSize(),
146
+ getPosition: () => candidate.getPosition(),
139
147
  restore: () => candidate.restore(),
140
148
  focus: () => candidate.focus(),
141
149
  close: () => candidate.close(),
@@ -20,19 +20,33 @@ export interface Rect {
20
20
  export const CASCADE_STEP = 32
21
21
 
22
22
  /**
23
- * Where a new window goes so that it does not cover another one exactly: down and to the right of `from` by one step, and back at the top left of the work area when that would put
24
- * it past the edge of the screen (so a row of windows cascades, then starts again).
23
+ * Where a new window goes so that it does not cover another one exactly. The first free place of: a step down and to the right of `from`, and again, while the window still fits on the
24
+ * screen; then the same steps from the top left of the work area (a row of windows cascades, then starts again); and, on a screen no bigger than the window, the steps from `from` even
25
+ * though the window runs past the edge (two windows at one place cannot be told apart). A place is free when no window in `taken` is there.
25
26
  */
26
27
  export function cascade(
27
28
  from: Rect,
28
29
  work: Rect,
29
30
  size: { width: number; height: number },
31
+ taken: readonly { x: number; y: number }[] = [],
30
32
  ): { x: number; y: number } {
31
- const x = from.x + CASCADE_STEP
32
- const y = from.y + CASCADE_STEP
33
- const fitsRight = x + size.width <= work.x + work.width
34
- const fitsBottom = y + size.height <= work.y + work.height
35
- return { x: fitsRight ? x : work.x, y: fitsBottom ? y : work.y }
33
+ const free = (place: { x: number; y: number }): boolean =>
34
+ !taken.some((other) => other.x === place.x && other.y === place.y)
35
+ const fits = (place: { x: number; y: number }): boolean =>
36
+ place.x + size.width <= work.x + work.width && place.y + size.height <= work.y + work.height
37
+ const steps = (origin: { x: number; y: number }): { x: number; y: number }[] =>
38
+ Array.from({ length: 40 }, (_, at) => ({
39
+ x: origin.x + CASCADE_STEP * (at + 1),
40
+ y: origin.y + CASCADE_STEP * (at + 1),
41
+ }))
42
+ const fromHere = steps(from)
43
+ const fromCorner = [{ x: work.x, y: work.y }, ...steps(work)]
44
+ return (
45
+ fromHere.find((place) => fits(place) && free(place)) ??
46
+ fromCorner.find((place) => fits(place) && free(place)) ??
47
+ fromHere.find(free) ??
48
+ fromHere[0] ?? { x: from.x + CASCADE_STEP, y: from.y + CASCADE_STEP }
49
+ )
36
50
  }
37
51
 
38
52
  /**