@x-tech-laboratories/x-libs 0.4.0 → 0.6.0
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.
- package/README.md +63 -13
- package/dist/dates.d.ts +9 -0
- package/dist/dates.js +60 -0
- package/dist/durations.d.ts +8 -1
- package/dist/durations.js +9 -5
- package/dist/electron/bridge.d.ts +30 -0
- package/dist/electron/bridge.js +32 -0
- package/dist/electron/window.d.ts +85 -0
- package/dist/electron/window.js +85 -0
- package/dist/web/menus.d.ts +4 -0
- package/dist/web/menus.js +28 -1
- package/dist/web/{preact → react}/badges.d.ts +9 -9
- package/dist/web/{preact → react}/badges.js +6 -6
- package/dist/web/{preact → react}/controls.d.ts +7 -7
- package/dist/web/{preact → react}/controls.js +7 -6
- package/dist/web/{preact → react}/dialog.d.ts +10 -10
- package/dist/web/{preact → react}/dialog.js +14 -9
- package/dist/web/{preact → react}/layout.d.ts +33 -33
- package/dist/web/react/layout.js +71 -0
- package/dist/web/react/menus.d.ts +31 -0
- package/dist/web/react/menus.js +159 -0
- package/dist/web/react/shell.d.ts +37 -0
- package/dist/web/react/shell.js +81 -0
- package/dist/web/react/shortcuts.d.ts +23 -0
- package/dist/web/react/shortcuts.js +95 -0
- package/dist/web/{preact → react}/split.d.ts +4 -4
- package/dist/web/{preact → react}/split.js +9 -10
- package/dist/web/{preact → react}/states.d.ts +7 -7
- package/dist/web/{preact → react}/states.js +6 -5
- package/dist/web/react/table.d.ts +46 -0
- package/dist/web/react/table.js +210 -0
- package/dist/web/{preact → react}/time.d.ts +1 -1
- package/dist/web/{preact → react}/time.js +1 -1
- package/dist/web/{preact → react}/toasts.d.ts +3 -1
- package/dist/web/react/toasts.js +41 -0
- package/dist/web/react/tree.d.ts +23 -0
- package/dist/web/react/tree.js +168 -0
- package/dist/web/tables.d.ts +18 -1
- package/dist/web/tables.js +70 -8
- package/dist/web/theme.d.ts +6 -1
- package/dist/web/theme.js +31 -21
- package/dist/web/tree.d.ts +35 -0
- package/dist/web/tree.js +107 -0
- package/package.json +15 -4
- package/dist/web/preact/layout.js +0 -71
- package/dist/web/preact/menus.d.ts +0 -19
- package/dist/web/preact/menus.js +0 -70
- package/dist/web/preact/shell.d.ts +0 -27
- package/dist/web/preact/shell.js +0 -63
- package/dist/web/preact/table.d.ts +0 -23
- package/dist/web/preact/table.js +0 -20
- package/dist/web/preact/toasts.js +0 -34
package/README.md
CHANGED
|
@@ -6,7 +6,10 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
|
|
|
6
6
|
|
|
7
7
|
| Import | What it holds |
|
|
8
8
|
| ---------------------------------------- | ----------------------------------------------------------------------------- |
|
|
9
|
-
| `durations` | `parse_duration` for settings such as `"2h30m"`, `format_duration
|
|
9
|
+
| `durations` | `parse_duration` for settings such as `"2h30m"`, `format_duration` in months down to seconds, `format_age`. |
|
|
10
|
+
| `dates` | Local dates as `"2026-06-10"` or `"2026-06-10T09:30"`: reading, writing, adding days and months, overdue, and Yesterday/Today/Tomorrow labels. |
|
|
11
|
+
| `electron/bridge` | `define_bridge`: one method table for the preload, the main process, and a web page's calls to its server. |
|
|
12
|
+
| `electron/window` | `create_window`: links open in the browser, the background follows the system theme, and the size and place are remembered. |
|
|
10
13
|
| `agent/commands` | `run_command` and `stream_command`: one shell command, in its own process group. |
|
|
11
14
|
| `agent/long_runs` | `LongRuns`: commands that outlive the agent, their output and exit code kept on disk. |
|
|
12
15
|
| `agent/keys` | Reads and makes OpenSSH ed25519 agent keys; signs and checks the login challenge. |
|
|
@@ -17,13 +20,16 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
|
|
|
17
20
|
| `server/agents/connection`, `agent`, `local`, `route` | One connected agent, the `Agent` interface, `LocalAgent`, and the Fastify route. |
|
|
18
21
|
| `server/passwords` | scrypt hashes and the `name:hash` accounts file. |
|
|
19
22
|
| `server/hash_password` | The `hash-password` command. |
|
|
20
|
-
| `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters, hash router and theme choice. |
|
|
21
|
-
| `web/states`, `web/tables`, `web/menus`, `web/split` | `LoginNeeded`;
|
|
22
|
-
| `web/
|
|
23
|
-
| `web/
|
|
24
|
-
| `web/
|
|
25
|
-
| `web/
|
|
26
|
-
| `web/
|
|
23
|
+
| `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters, hash router and theme choice, kept in this browser or the app's settings. |
|
|
24
|
+
| `web/states`, `web/tables`, `web/menus`, `web/split` | `LoginNeeded`; sorting by several columns, trees and column order; menu items, placement and keys; pane sizing. |
|
|
25
|
+
| `web/tree` | Rows with `parent_id` and `position`: nesting, subtrees, and where a dropped or keyed row lands. |
|
|
26
|
+
| `web/react/shell` | `Shell`: top bar, section links or the app's own sidebar, resizable, theme button, toasts, dialogs and menus; `use_route`. |
|
|
27
|
+
| `web/react/layout`, `badges`, `states` | Panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows; badges and chips; `use_resource` and its loading states. |
|
|
28
|
+
| `web/react/time` | `Age`: a time as how long ago, with the full stamp on hover. |
|
|
29
|
+
| `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes, selects; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, and cards on phones; a pager. |
|
|
30
|
+
| `web/react/shortcuts` | `use_shortcuts` for keys such as `"mod+z"`, quiet while typing or while a dialog or popover is open; `describe_keys` for help text. |
|
|
31
|
+
| `web/react/tree` | `Tree`: nested rows that fold, walk with the arrow keys, and reorder by dragging or with Alt and the arrow keys. |
|
|
32
|
+
| `web/react/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo that say how they closed; modal, confirm and text dialogs openable from anywhere; context menus and popovers worked by keyboard, openable from anywhere; resize handles and splits. |
|
|
27
33
|
|
|
28
34
|
```ts
|
|
29
35
|
import { AgentHub } from "@x-tech-laboratories/x-libs/server/agents/hub";
|
|
@@ -38,13 +44,57 @@ which an app imports once:
|
|
|
38
44
|
```ts
|
|
39
45
|
import "@x-tech-laboratories/halon/halon-tokens.css";
|
|
40
46
|
import "@x-tech-laboratories/halon/halon.css";
|
|
41
|
-
import { DataTable } from "@x-tech-laboratories/x-libs/web/
|
|
42
|
-
import { toast } from "@x-tech-laboratories/x-libs/web/
|
|
47
|
+
import { DataTable } from "@x-tech-laboratories/x-libs/web/react/table";
|
|
48
|
+
import { toast } from "@x-tech-laboratories/x-libs/web/react/toasts";
|
|
43
49
|
```
|
|
44
50
|
|
|
45
|
-
The widgets are
|
|
46
|
-
|
|
47
|
-
`
|
|
51
|
+
The widgets are React components, so a page renders with React through `createRoot` from `react-dom/client`; `Shell`
|
|
52
|
+
holds the toasts, dialogs and menus that `toast`, `confirm_dialog` and `open_context_menu` open from anywhere. `DataTable`
|
|
53
|
+
is drawn on `@tanstack/react-table` with Halon's markup; its sorting, column order, widths, hidden columns and open rows
|
|
54
|
+
are props with change callbacks, so the app keeps them in its own settings, and a column's filter is the app's own
|
|
55
|
+
content in a popover X-Libs opens from the column's funnel. A request whose session has lapsed
|
|
56
|
+
throws `LoginNeeded` from `web/states`, and `use_resource` passes it on as an unhandled rejection, which the page's
|
|
57
|
+
login listens for.
|
|
58
|
+
|
|
59
|
+
## Shortcuts and trees
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { use_shortcuts } from "@x-tech-laboratories/x-libs/web/react/shortcuts";
|
|
63
|
+
import { Tree } from "@x-tech-laboratories/x-libs/web/react/tree";
|
|
64
|
+
|
|
65
|
+
use_shortcuts([
|
|
66
|
+
{ keys: "mod+z", run: undo },
|
|
67
|
+
{ keys: "escape", run: clear_picks },
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
<Tree items={tags} label="Tags" render_item={(tag) => tag.name} collapsed={collapsed} on_collapsed_change={set_collapsed}
|
|
71
|
+
on_move={(tag, { parent_id, position }) => move_tag(tag.id, parent_id, position)} />
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A shortcut stays quiet in text fields unless it has `when: "always"`, and while a dialog or popover is open unless it
|
|
75
|
+
has `over_overlays: true`. The tree builds Halon's `.tree` markup; a row dropped onto its top or bottom quarter lands
|
|
76
|
+
beside it, and onto its middle inside it.
|
|
77
|
+
|
|
78
|
+
## Electron apps
|
|
79
|
+
|
|
80
|
+
The bridge and window helpers describe the few parts of Electron they use, so the app passes Electron's own objects in
|
|
81
|
+
and X-Libs does not depend on Electron.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// shared
|
|
85
|
+
export const notes_bridge = define_bridge<NotesApi>("x_notes", ["list_notes", "add_note"]);
|
|
86
|
+
// preload
|
|
87
|
+
notes_bridge.expose(contextBridge, ipcRenderer);
|
|
88
|
+
// main
|
|
89
|
+
notes_bridge.handle(ipcMain, { list_notes, add_note });
|
|
90
|
+
create_window(BrowserWindow, {
|
|
91
|
+
title: "X-Notes", file: join(__dirname, "../renderer/index.html"), dev_url: process.env.ELECTRON_RENDERER_URL,
|
|
92
|
+
preload: join(__dirname, "../preload/index.mjs"), default_size: { width: 1100, height: 700 },
|
|
93
|
+
state_file: join(app.getPath("userData"), "window.json"), native_theme: nativeTheme, shell,
|
|
94
|
+
});
|
|
95
|
+
// web page
|
|
96
|
+
window.x_notes = notes_bridge.web_rpc((method, args) => post_json(`api/${method}`, args));
|
|
97
|
+
```
|
|
48
98
|
|
|
49
99
|
## The app and its tasks
|
|
50
100
|
|
package/dist/dates.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function has_time(local_date: string): boolean;
|
|
2
|
+
export declare function parse_local(local_date: string): Date;
|
|
3
|
+
export declare function format_local(date: Date, with_time: boolean): string;
|
|
4
|
+
export declare function start_of_day(date: Date): Date;
|
|
5
|
+
export declare function days_between(first: Date, second: Date): number;
|
|
6
|
+
export declare function add_days(date: Date, days: number): Date;
|
|
7
|
+
export declare function add_months(date: Date, months: number): Date;
|
|
8
|
+
export declare function is_overdue(local_date: string, now?: Date): boolean;
|
|
9
|
+
export declare function relative_day_label(local_date: string, now?: Date, locale?: string): string;
|
package/dist/dates.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Local calendar dates written as "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM", read and written in the device's own time zone.
|
|
2
|
+
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
|
|
3
|
+
// ── Text and dates ───────────────────────────────────────────────────────────
|
|
4
|
+
export function has_time(local_date) {
|
|
5
|
+
return local_date.includes("T");
|
|
6
|
+
}
|
|
7
|
+
// A date without a time stands for midnight at the start of its day.
|
|
8
|
+
export function parse_local(local_date) {
|
|
9
|
+
const [day = "", time = "00:00"] = local_date.split("T");
|
|
10
|
+
const [year = 0, month = 1, day_of_month = 1] = day.split("-").map(Number);
|
|
11
|
+
const [hours = 0, minutes = 0] = time.split(":").map(Number);
|
|
12
|
+
return new Date(year, month - 1, day_of_month, hours, minutes);
|
|
13
|
+
}
|
|
14
|
+
export function format_local(date, with_time) {
|
|
15
|
+
const pad = (number) => String(number).padStart(2, "0");
|
|
16
|
+
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
17
|
+
return with_time ? `${day}T${pad(date.getHours())}:${pad(date.getMinutes())}` : day;
|
|
18
|
+
}
|
|
19
|
+
// ── Arithmetic ───────────────────────────────────────────────────────────────
|
|
20
|
+
export function start_of_day(date) {
|
|
21
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
22
|
+
}
|
|
23
|
+
// Whole calendar days from the first date to the second, so a clock change in between still counts as one day.
|
|
24
|
+
export function days_between(first, second) {
|
|
25
|
+
return Math.round((start_of_day(second).getTime() - start_of_day(first).getTime()) / DAY_IN_MILLISECONDS);
|
|
26
|
+
}
|
|
27
|
+
export function add_days(date, days) {
|
|
28
|
+
const moved = new Date(date);
|
|
29
|
+
moved.setDate(moved.getDate() + days);
|
|
30
|
+
return moved;
|
|
31
|
+
}
|
|
32
|
+
// A day past the end of the target month falls back to its last day, so Jan 31 plus a month is Feb 28.
|
|
33
|
+
export function add_months(date, months) {
|
|
34
|
+
const last_day = new Date(date.getFullYear(), date.getMonth() + months + 1, 0).getDate();
|
|
35
|
+
return new Date(date.getFullYear(), date.getMonth() + months, Math.min(date.getDate(), last_day), date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
|
|
36
|
+
}
|
|
37
|
+
// ── Labels ───────────────────────────────────────────────────────────────────
|
|
38
|
+
// A date without a time is overdue only once its day has passed.
|
|
39
|
+
export function is_overdue(local_date, now = new Date()) {
|
|
40
|
+
const date = parse_local(local_date);
|
|
41
|
+
return has_time(local_date) ? date < now : date < start_of_day(now);
|
|
42
|
+
}
|
|
43
|
+
// Yesterday, Today or Tomorrow in the reader's language, else a short date with the year only when it is not this year, then the time if there is one.
|
|
44
|
+
export function relative_day_label(local_date, now = new Date(), locale) {
|
|
45
|
+
const date = parse_local(local_date);
|
|
46
|
+
const day_offset = days_between(now, date);
|
|
47
|
+
const day_label = Math.abs(day_offset) <= 1
|
|
48
|
+
? capitalised(new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(day_offset, "day"), locale)
|
|
49
|
+
: date.toLocaleDateString(locale, {
|
|
50
|
+
month: "short",
|
|
51
|
+
day: "numeric",
|
|
52
|
+
year: date.getFullYear() === now.getFullYear() ? undefined : "numeric",
|
|
53
|
+
});
|
|
54
|
+
if (!has_time(local_date))
|
|
55
|
+
return day_label;
|
|
56
|
+
return `${day_label} ${date.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" })}`;
|
|
57
|
+
}
|
|
58
|
+
function capitalised(text, locale) {
|
|
59
|
+
return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
|
|
60
|
+
}
|
package/dist/durations.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
declare const unit_names: readonly [readonly ["months", 2592000], readonly ["weeks", 604800], readonly ["days", 86400], readonly ["hours", 3600], readonly ["minutes", 60], readonly ["seconds", 1]];
|
|
2
|
+
export type DurationUnit = (typeof unit_names)[number][0];
|
|
3
|
+
export interface DurationWording {
|
|
4
|
+
separator?: string;
|
|
5
|
+
largest_unit?: DurationUnit;
|
|
6
|
+
}
|
|
1
7
|
export declare function parse_duration(value: unknown, key: string): number;
|
|
2
|
-
export declare function format_duration(seconds: number): string;
|
|
8
|
+
export declare function format_duration(seconds: number, wording?: DurationWording): string;
|
|
3
9
|
export declare function format_age(seconds: number): string;
|
|
10
|
+
export {};
|
package/dist/durations.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Reads durations such as "2h30m" from settings and writes them back in words, for the server and the agent alike.
|
|
2
2
|
import parse from "parse-duration";
|
|
3
|
-
|
|
3
|
+
// A month counts as 30 days.
|
|
4
|
+
const unit_names = [["months", 2592000], ["weeks", 604800], ["days", 86400], ["hours", 3600], ["minutes", 60], ["seconds", 1]];
|
|
4
5
|
const words = new Intl.DurationFormat("en", { style: "long" });
|
|
5
6
|
// A duration in whole seconds; `key` names the setting in the error.
|
|
6
7
|
export function parse_duration(value, key) {
|
|
@@ -14,13 +15,14 @@ export function parse_duration(value, key) {
|
|
|
14
15
|
}
|
|
15
16
|
return Math.floor(seconds);
|
|
16
17
|
}
|
|
17
|
-
// The two largest whole units, such as "1 day, 2 hours".
|
|
18
|
-
export function format_duration(seconds) {
|
|
18
|
+
// The two largest whole units, such as "1 day, 2 hours" or, with a space between, "1 week 2 days".
|
|
19
|
+
export function format_duration(seconds, wording = {}) {
|
|
19
20
|
if (seconds < 1)
|
|
20
21
|
return "0 seconds";
|
|
22
|
+
const first_unit = unit_names.findIndex(([name]) => name === (wording.largest_unit ?? "months"));
|
|
21
23
|
const parts = {};
|
|
22
24
|
let remaining = Math.floor(seconds);
|
|
23
|
-
for (const [name, size] of unit_names) {
|
|
25
|
+
for (const [name, size] of unit_names.slice(first_unit)) {
|
|
24
26
|
if (Object.keys(parts).length === 2)
|
|
25
27
|
break;
|
|
26
28
|
if (remaining < size)
|
|
@@ -28,7 +30,9 @@ export function format_duration(seconds) {
|
|
|
28
30
|
parts[name] = Math.floor(remaining / size);
|
|
29
31
|
remaining %= size;
|
|
30
32
|
}
|
|
31
|
-
|
|
33
|
+
if (wording.separator === undefined)
|
|
34
|
+
return words.format(parts);
|
|
35
|
+
return Object.entries(parts).map(([name, count]) => words.format({ [name]: count })).join(wording.separator);
|
|
32
36
|
}
|
|
33
37
|
export function format_age(seconds) {
|
|
34
38
|
return seconds < 0 ? "never" : `${format_duration(seconds)} ago`;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface IpcMainLike {
|
|
2
|
+
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
|
|
3
|
+
removeHandler(channel: string): void;
|
|
4
|
+
}
|
|
5
|
+
export interface IpcRendererLike {
|
|
6
|
+
invoke(channel: string, ...args: unknown[]): Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface ContextBridgeLike {
|
|
9
|
+
exposeInMainWorld(api_key: string, api: unknown): void;
|
|
10
|
+
}
|
|
11
|
+
type AnyMethod = (...args: never[]) => unknown;
|
|
12
|
+
export type Client<Methods extends Record<keyof Methods, AnyMethod>> = {
|
|
13
|
+
[Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => Promise<Awaited<ReturnType<Methods[Name]>>>;
|
|
14
|
+
};
|
|
15
|
+
export type Implementation<Methods extends Record<keyof Methods, AnyMethod>> = {
|
|
16
|
+
[Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => ReturnType<Methods[Name]> | Promise<Awaited<ReturnType<Methods[Name]>>>;
|
|
17
|
+
};
|
|
18
|
+
export type CallRemote = (method: string, args: unknown[]) => Promise<unknown>;
|
|
19
|
+
export interface Bridge<Methods extends Record<keyof Methods, AnyMethod>> {
|
|
20
|
+
api_name: string;
|
|
21
|
+
method_names: readonly (keyof Methods & string)[];
|
|
22
|
+
channel: (method: keyof Methods & string) => string;
|
|
23
|
+
expose: (context_bridge: ContextBridgeLike, ipc_renderer: IpcRendererLike) => void;
|
|
24
|
+
ipc_client: (ipc_renderer: IpcRendererLike) => Client<Methods>;
|
|
25
|
+
handle: (ipc_main: IpcMainLike, implementation: Implementation<Methods>) => () => void;
|
|
26
|
+
web_rpc: (call_remote: CallRemote) => Client<Methods>;
|
|
27
|
+
dispatch: (implementation: Implementation<Methods>, method: string, args: unknown[]) => Promise<unknown>;
|
|
28
|
+
}
|
|
29
|
+
export declare function define_bridge<Methods extends Record<keyof Methods, AnyMethod>>(api_name: string, method_names: readonly (keyof Methods & string)[]): Bridge<Methods>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// One table of method names serves the Electron preload, the main process and a web page's calls to its server alike.
|
|
2
|
+
export function define_bridge(api_name, method_names) {
|
|
3
|
+
const channel = (method) => `${api_name}:${method}`;
|
|
4
|
+
const is_method = (method) => method_names.includes(method);
|
|
5
|
+
function client_through(call) {
|
|
6
|
+
return Object.fromEntries(method_names.map((method) => [method, (...args) => call(method, args)]));
|
|
7
|
+
}
|
|
8
|
+
async function dispatch(implementation, method, args) {
|
|
9
|
+
if (!is_method(method))
|
|
10
|
+
throw new Error(`${api_name} has no method ${method}`);
|
|
11
|
+
const answer = implementation[method];
|
|
12
|
+
return answer.apply(implementation, args);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
api_name,
|
|
16
|
+
method_names,
|
|
17
|
+
channel,
|
|
18
|
+
dispatch,
|
|
19
|
+
ipc_client: (ipc_renderer) => client_through((method, args) => ipc_renderer.invoke(channel(method), ...args)),
|
|
20
|
+
expose: (context_bridge, ipc_renderer) => {
|
|
21
|
+
context_bridge.exposeInMainWorld(api_name, client_through((method, args) => ipc_renderer.invoke(channel(method), ...args)));
|
|
22
|
+
},
|
|
23
|
+
handle: (ipc_main, implementation) => {
|
|
24
|
+
for (const method of method_names) {
|
|
25
|
+
ipc_main.handle(channel(method), (_event, ...args) => dispatch(implementation, method, args));
|
|
26
|
+
}
|
|
27
|
+
return () => { for (const method of method_names)
|
|
28
|
+
ipc_main.removeHandler(channel(method)); };
|
|
29
|
+
},
|
|
30
|
+
web_rpc: client_through,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface Rectangle {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
export interface BrowserWindowOptionsLike {
|
|
8
|
+
title: string;
|
|
9
|
+
icon?: string;
|
|
10
|
+
x?: number;
|
|
11
|
+
y?: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
minWidth?: number;
|
|
15
|
+
minHeight?: number;
|
|
16
|
+
show: boolean;
|
|
17
|
+
backgroundColor: string;
|
|
18
|
+
webPreferences: {
|
|
19
|
+
preload?: string;
|
|
20
|
+
sandbox: boolean;
|
|
21
|
+
contextIsolation: boolean;
|
|
22
|
+
nodeIntegration: boolean;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export interface BrowserWindowLike {
|
|
26
|
+
webContents: {
|
|
27
|
+
setWindowOpenHandler(handler: (details: {
|
|
28
|
+
url: string;
|
|
29
|
+
}) => {
|
|
30
|
+
action: "deny";
|
|
31
|
+
}): void;
|
|
32
|
+
};
|
|
33
|
+
on(event: string, listener: () => void): unknown;
|
|
34
|
+
once(event: string, listener: () => void): unknown;
|
|
35
|
+
show(): void;
|
|
36
|
+
maximize(): void;
|
|
37
|
+
isMaximized(): boolean;
|
|
38
|
+
getNormalBounds(): Rectangle;
|
|
39
|
+
setBackgroundColor(color: string): void;
|
|
40
|
+
loadURL(url: string): Promise<void>;
|
|
41
|
+
loadFile(path: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export interface NativeThemeLike {
|
|
44
|
+
shouldUseDarkColors: boolean;
|
|
45
|
+
on(event: "updated", listener: () => void): unknown;
|
|
46
|
+
removeListener(event: "updated", listener: () => void): unknown;
|
|
47
|
+
}
|
|
48
|
+
export interface ShellLike {
|
|
49
|
+
openExternal(url: string): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export interface ScreenLike {
|
|
52
|
+
getAllDisplays(): {
|
|
53
|
+
workArea: Rectangle;
|
|
54
|
+
}[];
|
|
55
|
+
}
|
|
56
|
+
export interface Size {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
}
|
|
60
|
+
export interface WindowOptions {
|
|
61
|
+
title: string;
|
|
62
|
+
icon?: string;
|
|
63
|
+
preload?: string;
|
|
64
|
+
dev_url?: string;
|
|
65
|
+
file: string;
|
|
66
|
+
default_size: Size;
|
|
67
|
+
min_size?: Size;
|
|
68
|
+
state_file?: string;
|
|
69
|
+
native_theme: NativeThemeLike;
|
|
70
|
+
shell: ShellLike;
|
|
71
|
+
screen?: ScreenLike;
|
|
72
|
+
background?: {
|
|
73
|
+
light: string;
|
|
74
|
+
dark: string;
|
|
75
|
+
};
|
|
76
|
+
sandbox?: boolean;
|
|
77
|
+
save_delay_ms?: number;
|
|
78
|
+
}
|
|
79
|
+
export interface WindowState {
|
|
80
|
+
bounds: Rectangle;
|
|
81
|
+
is_maximized: boolean;
|
|
82
|
+
}
|
|
83
|
+
export declare function read_window_state(state_file: string): WindowState | null;
|
|
84
|
+
export declare function write_window_state(state_file: string, state: WindowState): void;
|
|
85
|
+
export declare function create_window<Window extends BrowserWindowLike>(BrowserWindow: new (options: BrowserWindowOptionsLike) => Window, options: WindowOptions): Window;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// An Electron app's main window: links open in the browser, the background follows the system theme, and its size and place are remembered.
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
const DEFAULT_BACKGROUND = { light: "#f5f6f8", dark: "#0f1115" };
|
|
5
|
+
const DEFAULT_SAVE_DELAY_MS = 500;
|
|
6
|
+
// ── State file ───────────────────────────────────────────────────────────────
|
|
7
|
+
// A missing, unreadable or malformed file reads as no saved state.
|
|
8
|
+
export function read_window_state(state_file) {
|
|
9
|
+
try {
|
|
10
|
+
const state = JSON.parse(readFileSync(state_file, "utf8"));
|
|
11
|
+
const bounds = state.bounds;
|
|
12
|
+
const is_whole = (value) => Number.isInteger(value);
|
|
13
|
+
if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(is_whole) || bounds.width <= 0 || bounds.height <= 0)
|
|
14
|
+
return null;
|
|
15
|
+
return { bounds, is_maximized: state.is_maximized === true };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Writes beside the file and renames over it, so a crash mid-write never leaves half a file.
|
|
22
|
+
export function write_window_state(state_file, state) {
|
|
23
|
+
mkdirSync(dirname(state_file), { recursive: true });
|
|
24
|
+
const temporary_file = `${state_file}.tmp`;
|
|
25
|
+
writeFileSync(temporary_file, JSON.stringify(state));
|
|
26
|
+
renameSync(temporary_file, state_file);
|
|
27
|
+
}
|
|
28
|
+
function overlaps(first, second) {
|
|
29
|
+
return first.x < second.x + second.width && second.x < first.x + first.width && first.y < second.y + second.height && second.y < first.y + first.height;
|
|
30
|
+
}
|
|
31
|
+
// ── Window ───────────────────────────────────────────────────────────────────
|
|
32
|
+
export function create_window(BrowserWindow, options) {
|
|
33
|
+
const background = options.background ?? DEFAULT_BACKGROUND;
|
|
34
|
+
const background_color = () => options.native_theme.shouldUseDarkColors ? background.dark : background.light;
|
|
35
|
+
const saved = options.state_file ? read_window_state(options.state_file) : null;
|
|
36
|
+
const on_a_screen = saved && (!options.screen || options.screen.getAllDisplays().some((display) => overlaps(saved.bounds, display.workArea)));
|
|
37
|
+
const size = saved?.bounds ?? options.default_size;
|
|
38
|
+
const window = new BrowserWindow({
|
|
39
|
+
title: options.title,
|
|
40
|
+
icon: options.icon,
|
|
41
|
+
...(on_a_screen ? { x: saved.bounds.x, y: saved.bounds.y } : {}),
|
|
42
|
+
width: size.width,
|
|
43
|
+
height: size.height,
|
|
44
|
+
minWidth: options.min_size?.width,
|
|
45
|
+
minHeight: options.min_size?.height,
|
|
46
|
+
show: false,
|
|
47
|
+
backgroundColor: background_color(),
|
|
48
|
+
webPreferences: { preload: options.preload, sandbox: options.sandbox ?? false, contextIsolation: true, nodeIntegration: false },
|
|
49
|
+
});
|
|
50
|
+
window.once("ready-to-show", () => {
|
|
51
|
+
if (saved?.is_maximized)
|
|
52
|
+
window.maximize();
|
|
53
|
+
window.show();
|
|
54
|
+
});
|
|
55
|
+
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
56
|
+
void options.shell.openExternal(url);
|
|
57
|
+
return { action: "deny" };
|
|
58
|
+
});
|
|
59
|
+
const follow_theme = () => window.setBackgroundColor(background_color());
|
|
60
|
+
options.native_theme.on("updated", follow_theme);
|
|
61
|
+
window.on("closed", () => options.native_theme.removeListener("updated", follow_theme));
|
|
62
|
+
if (options.state_file)
|
|
63
|
+
remember_state(window, options.state_file, options.save_delay_ms ?? DEFAULT_SAVE_DELAY_MS);
|
|
64
|
+
if (options.dev_url)
|
|
65
|
+
void window.loadURL(options.dev_url);
|
|
66
|
+
else
|
|
67
|
+
void window.loadFile(options.file);
|
|
68
|
+
return window;
|
|
69
|
+
}
|
|
70
|
+
// Saves a moment after the last move or resize, and at once when the window closes.
|
|
71
|
+
function remember_state(window, state_file, save_delay_ms) {
|
|
72
|
+
let timer;
|
|
73
|
+
const save_now = () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
timer = undefined;
|
|
76
|
+
write_window_state(state_file, { bounds: window.getNormalBounds(), is_maximized: window.isMaximized() });
|
|
77
|
+
};
|
|
78
|
+
const save_soon = () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
timer = setTimeout(save_now, save_delay_ms);
|
|
81
|
+
};
|
|
82
|
+
for (const event of ["resize", "move", "maximize", "unmaximize"])
|
|
83
|
+
window.on(event, save_soon);
|
|
84
|
+
window.on("close", save_now);
|
|
85
|
+
}
|
package/dist/web/menus.d.ts
CHANGED
|
@@ -6,8 +6,12 @@ export interface MenuItem {
|
|
|
6
6
|
danger?: boolean;
|
|
7
7
|
separator_before?: boolean;
|
|
8
8
|
checked?: boolean;
|
|
9
|
+
radio?: boolean;
|
|
10
|
+
keep_open?: boolean;
|
|
9
11
|
}
|
|
10
12
|
export declare function on_screen_position(x: number, y: number, width: number, height: number, room_width?: number, room_height?: number): {
|
|
11
13
|
left: number;
|
|
12
14
|
top: number;
|
|
13
15
|
};
|
|
16
|
+
export declare function item_after_key(key: string, current: number, count: number): number | null;
|
|
17
|
+
export declare function item_starting_with(labels: readonly string[], typed: string, current: number): number | null;
|
package/dist/web/menus.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What a menu item holds,
|
|
1
|
+
// What a menu item holds, where a menu opened at a point goes so it stays on screen, and which item a key moves to.
|
|
2
2
|
const edge_gap = 4;
|
|
3
3
|
// Flipped to the point's other side near an edge, or pushed back from the edge when neither side has room.
|
|
4
4
|
export function on_screen_position(x, y, width, height, room_width = window.innerWidth, room_height = window.innerHeight) {
|
|
@@ -11,3 +11,30 @@ export function on_screen_position(x, y, width, height, room_width = window.inne
|
|
|
11
11
|
};
|
|
12
12
|
return { left: place(x, width, room_width), top: place(y, height, room_height) };
|
|
13
13
|
}
|
|
14
|
+
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
// The item an arrow, Home or End key moves to from the current one, wrapping round, or null for any other key.
|
|
16
|
+
export function item_after_key(key, current, count) {
|
|
17
|
+
if (count === 0)
|
|
18
|
+
return null;
|
|
19
|
+
switch (key) {
|
|
20
|
+
case "ArrowDown": return current < 0 ? 0 : (current + 1) % count;
|
|
21
|
+
case "ArrowUp": return current < 0 ? count - 1 : (current - 1 + count) % count;
|
|
22
|
+
case "Home": return 0;
|
|
23
|
+
case "End": return count - 1;
|
|
24
|
+
default: return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// The first item after the current one whose label starts with the typed text, or null when none does.
|
|
28
|
+
export function item_starting_with(labels, typed, current) {
|
|
29
|
+
const wanted = typed.toLocaleLowerCase();
|
|
30
|
+
// A repeated first letter steps on through the items that start with it.
|
|
31
|
+
const repeated = wanted.length > 1 && [...wanted].every((letter) => letter === wanted[0]);
|
|
32
|
+
const prefix = repeated ? wanted[0] : wanted;
|
|
33
|
+
const start = repeated || wanted.length === 1 ? current + 1 : Math.max(current, 0);
|
|
34
|
+
for (let offset = 0; offset < labels.length; offset += 1) {
|
|
35
|
+
const index = (start + offset) % labels.length;
|
|
36
|
+
if (labels[index].trim().toLocaleLowerCase().startsWith(prefix))
|
|
37
|
+
return index;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
export type Tone = "good" | "warn" | "bad" | "info" | "none";
|
|
3
3
|
export declare function Badge({ tone, title, children }: {
|
|
4
4
|
tone?: Tone;
|
|
5
5
|
title?: string;
|
|
6
|
-
children:
|
|
7
|
-
}): import("
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
8
|
export declare function StateBadge<State extends string>({ labels, state, title }: {
|
|
9
9
|
labels: Record<State, [text: string, tone: Tone]>;
|
|
10
10
|
state: State;
|
|
11
11
|
title?: string;
|
|
12
|
-
}): import("
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
13
|
export declare function Chip({ title, on_click, class_name, children }: {
|
|
14
14
|
title?: string;
|
|
15
15
|
on_click?: () => void;
|
|
16
16
|
class_name?: string;
|
|
17
|
-
children:
|
|
18
|
-
}): import("
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
19
19
|
export declare function Chips({ inline, children }: {
|
|
20
20
|
inline?: boolean;
|
|
21
|
-
children:
|
|
22
|
-
}): import("
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}): import("react").JSX.Element;
|
|
23
23
|
export declare function Verdict({ ok, label }: {
|
|
24
24
|
ok: boolean | null | undefined;
|
|
25
25
|
label: string;
|
|
26
|
-
}): import("
|
|
26
|
+
}): import("react").JSX.Element;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// ── Badges ───────────────────────────────────────────────────────────────────
|
|
3
3
|
export function Badge({ tone = "none", title, children }) {
|
|
4
|
-
return _jsx("span", {
|
|
4
|
+
return _jsx("span", { className: `badge tone-${tone}`, title: title, children: children });
|
|
5
5
|
}
|
|
6
6
|
// A badge for one of a fixed set of states, each named by its text and tone.
|
|
7
7
|
export function StateBadge({ labels, state, title }) {
|
|
@@ -13,16 +13,16 @@ export function StateBadge({ labels, state, title }) {
|
|
|
13
13
|
export function Chip({ title, on_click, class_name, children }) {
|
|
14
14
|
const chip_class = class_name ? `chip ${class_name}` : "chip";
|
|
15
15
|
return on_click
|
|
16
|
-
? _jsx("button", { type: "button",
|
|
17
|
-
: _jsx("span", {
|
|
16
|
+
? _jsx("button", { type: "button", className: chip_class, title: title, onClick: on_click, children: children })
|
|
17
|
+
: _jsx("span", { className: chip_class, title: title, children: children });
|
|
18
18
|
}
|
|
19
19
|
// A row of chips or badges, with less space between them when they sit inside a line of text.
|
|
20
20
|
export function Chips({ inline = false, children }) {
|
|
21
|
-
return _jsx("span", {
|
|
21
|
+
return _jsx("span", { className: inline ? "chips inline-chips" : "chips", children: children });
|
|
22
22
|
}
|
|
23
23
|
// ── Verdicts ─────────────────────────────────────────────────────────────────
|
|
24
24
|
// A pass, fail or not-yet verdict, such as "checks: pass".
|
|
25
25
|
export function Verdict({ ok, label }) {
|
|
26
26
|
const known = ok !== null && ok !== undefined;
|
|
27
|
-
return _jsxs("span", {
|
|
27
|
+
return _jsxs("span", { className: `verdict tone-${known ? (ok ? "good" : "bad") : "none"}`, children: [label, ": ", known ? (ok ? "pass" : "fail") : "no verdict"] });
|
|
28
28
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
2
|
interface SegmentedProps<Value extends string> {
|
|
3
3
|
label: string;
|
|
4
4
|
choices: {
|
|
@@ -9,29 +9,29 @@ interface SegmentedProps<Value extends string> {
|
|
|
9
9
|
on_change: (value: Value) => void;
|
|
10
10
|
class_name?: string;
|
|
11
11
|
}
|
|
12
|
-
export declare function Segmented<Value extends string>({ label, choices, value, on_change, class_name }: SegmentedProps<Value>): import("
|
|
12
|
+
export declare function Segmented<Value extends string>({ label, choices, value, on_change, class_name }: SegmentedProps<Value>): import("react").JSX.Element;
|
|
13
13
|
export declare function ButtonGroup({ label, children }: {
|
|
14
14
|
label: string;
|
|
15
|
-
children:
|
|
16
|
-
}): import("
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
17
|
interface SwitchProps {
|
|
18
18
|
label: string;
|
|
19
19
|
checked: boolean;
|
|
20
20
|
on_change: (on: boolean) => Promise<boolean | void> | boolean | void;
|
|
21
21
|
disabled?: boolean;
|
|
22
22
|
}
|
|
23
|
-
export declare function Switch({ label, checked, on_change, disabled }: SwitchProps): import("
|
|
23
|
+
export declare function Switch({ label, checked, on_change, disabled }: SwitchProps): import("react").JSX.Element;
|
|
24
24
|
interface SearchBoxProps {
|
|
25
25
|
label: string;
|
|
26
26
|
value: string;
|
|
27
27
|
on_input: (value: string) => void;
|
|
28
28
|
}
|
|
29
|
-
export declare function SearchBox({ label, value, on_input }: SearchBoxProps): import("
|
|
29
|
+
export declare function SearchBox({ label, value, on_input }: SearchBoxProps): import("react").JSX.Element;
|
|
30
30
|
interface SelectProps<Value extends string> {
|
|
31
31
|
label: string;
|
|
32
32
|
options: [value: Value, text: string][];
|
|
33
33
|
value: Value;
|
|
34
34
|
on_change: (value: Value) => void;
|
|
35
35
|
}
|
|
36
|
-
export declare function Select<Value extends string>({ label, options, value, on_change }: SelectProps<Value>): import("
|
|
36
|
+
export declare function Select<Value extends string>({ label, options, value, on_change }: SelectProps<Value>): import("react").JSX.Element;
|
|
37
37
|
export {};
|