gloomberb 0.17.3 → 0.17.4
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/package.json +2 -1
- package/src/api-client/chat.ts +28 -0
- package/src/api-client/errors.ts +11 -1
- package/src/api-client/index.ts +4 -1
- package/src/api-client/normalizers.ts +21 -0
- package/src/api-client/socket.ts +27 -0
- package/src/app/runtime/startup.ts +4 -0
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/components/index.ts +1 -1
- package/src/components/metric-treemap/heat-surface.tsx +76 -14
- package/src/components/metric-treemap/heat-tiles.ts +5 -3
- package/src/components/metric-treemap/index.tsx +1 -1
- package/src/components/ui/button.tsx +24 -9
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/status.tsx +148 -5
- package/src/layout-marketplace/gallery-desktop.tsx +2 -2
- package/src/market-data/futures-board.ts +44 -1
- package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
- package/src/plugins/builtin/alerts/cli.ts +72 -11
- package/src/plugins/builtin/awards/headless.ts +5 -1
- package/src/plugins/builtin/catalysts/headless.ts +5 -1
- package/src/plugins/builtin/chat/controller/connections.ts +18 -0
- package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +49 -21
- package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
- package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
- package/src/plugins/builtin/cot/model.ts +20 -0
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/docs/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +2 -3
- package/src/plugins/builtin/futures/model.ts +4 -2
- package/src/plugins/builtin/futures/series.ts +3 -3
- package/src/plugins/builtin/jobs/pane.tsx +12 -9
- package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
- package/src/plugins/builtin/news/headless.ts +4 -2
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -3
- package/src/renderers/dom/build-assets.ts +17 -4
- package/src/renderers/dom/desktop/controls.tsx +2 -0
- package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
- package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
- package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
- package/src/sources/gloomberb-cloud/normalizers.ts +12 -4
- package/src/time-series/resolve.ts +5 -3
- package/src/ui/resize-settle.ts +128 -0
- package/src/utils/calendar-date.ts +56 -0
- package/src/utils/decimal-comma.ts +31 -0
- package/src/utils/futures-generic.ts +25 -4
- package/src/utils/plugin-proxy-hosts.json +5 -0
- package/src/utils/utc-time.ts +14 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gloomberb",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
4
4
|
"packageManager": "bun@1.3.11",
|
|
5
5
|
"description": "Market research and portfolio tracker for the terminal",
|
|
6
6
|
"module": "src/public/index.ts",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"check:pane-screenshot": "bun run scripts/pane-chrome/screenshot.ts",
|
|
87
87
|
"check:pane-rest": "bun run scripts/pane-chrome/rest-diff.ts",
|
|
88
88
|
"check:pane-layout": "bun run scripts/pane-chrome/layout-sweep.ts",
|
|
89
|
+
"audit:ui": "bun run scripts/pane-chrome/ui-audit.ts",
|
|
89
90
|
"benchmark:pane-drag:compare": "bun run scripts/pane-chrome/frame-budget.ts",
|
|
90
91
|
"test:global-upgrade": "bun run scripts/smoke-global-upgrade.ts",
|
|
91
92
|
"release": "./scripts/release.sh"
|
package/src/api-client/chat.ts
CHANGED
|
@@ -154,16 +154,40 @@ export class CloudChatApi {
|
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* `seen`: the app showed them where the reader was looking (the open
|
|
159
|
+
* channel, a toast in the foreground), which also cancels the phone push
|
|
160
|
+
* waiting on them. A server that predates it ignores the field.
|
|
161
|
+
*/
|
|
157
162
|
async markNotificationsDelivered(
|
|
158
163
|
notificationIds: string[],
|
|
164
|
+
options: { seen?: boolean } = {},
|
|
159
165
|
): Promise<{ delivered: number }> {
|
|
160
166
|
return this.request<{ delivered: number }>(
|
|
161
167
|
"/chat/notifications/delivered",
|
|
168
|
+
{
|
|
169
|
+
method: "POST",
|
|
170
|
+
body: JSON.stringify({ notificationIds, ...(options.seen ? { seen: true } : {}) }),
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The notifications among these the reader has neither read nor been shown
|
|
177
|
+
* in front of them, on any device. Throws on a server without the route
|
|
178
|
+
* (404); the caller raises the alert then, as before.
|
|
179
|
+
*/
|
|
180
|
+
async getUnseenNotificationIds(notificationIds: string[]): Promise<string[]> {
|
|
181
|
+
const answer = await this.request<{ notificationIds?: unknown }>(
|
|
182
|
+
"/chat/notifications/unseen",
|
|
162
183
|
{
|
|
163
184
|
method: "POST",
|
|
164
185
|
body: JSON.stringify({ notificationIds }),
|
|
165
186
|
},
|
|
166
187
|
);
|
|
188
|
+
return Array.isArray(answer?.notificationIds)
|
|
189
|
+
? answer.notificationIds.filter((id): id is string => typeof id === "string")
|
|
190
|
+
: notificationIds;
|
|
167
191
|
}
|
|
168
192
|
|
|
169
193
|
async openDirectChannel(target: {
|
|
@@ -327,6 +351,10 @@ export class CloudChatApi {
|
|
|
327
351
|
return this.options.socket.subscribeChatNotifications(listener);
|
|
328
352
|
}
|
|
329
353
|
|
|
354
|
+
subscribeChannelState(listener: (state: ChatChannelState) => void): () => void {
|
|
355
|
+
return this.options.socket.subscribeChatChannelState(listener);
|
|
356
|
+
}
|
|
357
|
+
|
|
330
358
|
subscribePresence(listener: ChatPresenceListener): () => void {
|
|
331
359
|
return this.options.socket.subscribeChatPresence(listener);
|
|
332
360
|
}
|
package/src/api-client/errors.ts
CHANGED
|
@@ -75,12 +75,22 @@ export function parseApiErrorMessage(body: string): string {
|
|
|
75
75
|
(value): value is string =>
|
|
76
76
|
typeof value === "string" && value.trim().length > 0,
|
|
77
77
|
);
|
|
78
|
-
return parts.join(" ") || body;
|
|
78
|
+
return parts.join(" ") || validationErrorText(parsed) || body;
|
|
79
79
|
} catch {
|
|
80
80
|
return htmlErrorTitle(body) ?? body;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* A request the API's schema refused comes back as the values it was sent
|
|
86
|
+
* (`{"type":"validation","on":"query","found":{...}}`), which is no message to
|
|
87
|
+
* show; it is said in words instead.
|
|
88
|
+
*/
|
|
89
|
+
function validationErrorText(parsed: Record<string, unknown>): string | null {
|
|
90
|
+
if (parsed.type !== "validation") return null;
|
|
91
|
+
return parsed.on === "query" ? "Gloom Cloud did not accept an option or filter value." : "Gloom Cloud did not accept the request.";
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
/** A JSON error body as an object, or undefined for any other body. */
|
|
85
95
|
export function parseApiErrorDetails(body: string): Readonly<Record<string, unknown>> | undefined {
|
|
86
96
|
try {
|
package/src/api-client/index.ts
CHANGED
|
@@ -60,7 +60,8 @@ export type {
|
|
|
60
60
|
/** Server-side caps for `/assist/command`; enforced here so a 422 is never sent. */
|
|
61
61
|
const ASSIST_QUERY_MAX_LENGTH = 200;
|
|
62
62
|
const ASSIST_COMMAND_LIMIT = 150;
|
|
63
|
-
|
|
63
|
+
/** The futures curve's root list is the longest a command sends: 76 since GLO-783. */
|
|
64
|
+
export const ASSIST_ARG_OPTION_LIMIT = 128;
|
|
64
65
|
const ASSIST_ARG_OPTION_VALUE_MAX_LENGTH = 40;
|
|
65
66
|
const ASSIST_ARG_OPTION_LABEL_MAX_LENGTH = 60;
|
|
66
67
|
const ASSIST_REQUEST_TIMEOUT_MS = 6_000;
|
|
@@ -646,6 +647,7 @@ class GloomApiClient {
|
|
|
646
647
|
getChatState = this.chat.getState.bind(this.chat);
|
|
647
648
|
updateChatChannelState = this.chat.updateChannelState.bind(this.chat);
|
|
648
649
|
markChatNotificationsDelivered = this.chat.markNotificationsDelivered.bind(this.chat);
|
|
650
|
+
getUnseenChatNotificationIds = this.chat.getUnseenNotificationIds.bind(this.chat);
|
|
649
651
|
openDirectChannel = this.chat.openDirectChannel.bind(this.chat);
|
|
650
652
|
openGroupChannel = this.chat.openGroupChannel.bind(this.chat);
|
|
651
653
|
getMessages = this.chat.getMessages.bind(this.chat);
|
|
@@ -658,6 +660,7 @@ class GloomApiClient {
|
|
|
658
660
|
subscribeChatReactions = this.chat.subscribeReactions.bind(this.chat);
|
|
659
661
|
connectChannel = this.chat.connectChannel.bind(this.chat);
|
|
660
662
|
subscribeChatNotifications = this.chat.subscribeNotifications.bind(this.chat);
|
|
663
|
+
subscribeChatChannelState = this.chat.subscribeChannelState.bind(this.chat);
|
|
661
664
|
subscribeChatPresence = this.chat.subscribePresence.bind(this.chat);
|
|
662
665
|
getChatDiscordLink = this.chat.getDiscordLink.bind(this.chat);
|
|
663
666
|
unlinkChatDiscord = this.chat.unlinkDiscord.bind(this.chat);
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ChatAccountType,
|
|
3
3
|
ChatAttachment,
|
|
4
4
|
ChatChannel,
|
|
5
|
+
ChatChannelState,
|
|
5
6
|
ChatDiscordLink,
|
|
6
7
|
ChatMessage,
|
|
7
8
|
ChatMessageOrigin,
|
|
@@ -191,6 +192,26 @@ export function normalizeChatNotification(
|
|
|
191
192
|
};
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/** A `chat.channel_state` frame, or null when it is not one. */
|
|
196
|
+
export function normalizeChatChannelStateFrame(data: unknown): ChatChannelState | null {
|
|
197
|
+
if (!data || typeof data !== "object") return null;
|
|
198
|
+
const state = data as Partial<ChatChannelState>;
|
|
199
|
+
if (
|
|
200
|
+
typeof state.channelId !== "string"
|
|
201
|
+
|| typeof state.notificationsEnabled !== "boolean"
|
|
202
|
+
|| typeof state.unreadCount !== "number"
|
|
203
|
+
|| (state.lastReadMessageId !== null && typeof state.lastReadMessageId !== "string")
|
|
204
|
+
) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
channelId: state.channelId,
|
|
209
|
+
notificationsEnabled: state.notificationsEnabled,
|
|
210
|
+
lastReadMessageId: state.lastReadMessageId,
|
|
211
|
+
unreadCount: state.unreadCount,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
194
215
|
export function normalizeChatChannel(
|
|
195
216
|
channel: ChatChannel,
|
|
196
217
|
fallbackKind: ChatChannel["kind"] = "public",
|
package/src/api-client/socket.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TapeFeedEvent } from "./tape";
|
|
2
2
|
import type {
|
|
3
3
|
AuthUser,
|
|
4
|
+
ChatChannelState,
|
|
4
5
|
ChatMessage,
|
|
5
6
|
ChatNotification,
|
|
6
7
|
ChatReactionsUpdate,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
TeamNotification,
|
|
12
13
|
} from "./types";
|
|
13
14
|
import {
|
|
15
|
+
normalizeChatChannelStateFrame,
|
|
14
16
|
normalizeChatMessage,
|
|
15
17
|
normalizeChatNotification,
|
|
16
18
|
normalizeChatReactions,
|
|
@@ -42,6 +44,7 @@ const cloudApiLog = debugLog.createLogger("cloud-api");
|
|
|
42
44
|
|
|
43
45
|
type ChannelListener = (message: ChatMessage) => void;
|
|
44
46
|
type ChatNotificationListener = (notification: ChatNotification) => void;
|
|
47
|
+
type ChatChannelStateListener = (state: ChatChannelState) => void;
|
|
45
48
|
type ChatPresenceListener = (onlineCount: number) => void;
|
|
46
49
|
type ChatReactionsListener = (update: ChatReactionsUpdate) => void;
|
|
47
50
|
type TeamNotificationListener = (notification: TeamNotification) => void;
|
|
@@ -108,6 +111,8 @@ export class CloudApiSocket {
|
|
|
108
111
|
private readonly channelListeners = new Map<string, Set<ChannelListener>>();
|
|
109
112
|
private readonly chatNotificationListeners =
|
|
110
113
|
new Set<ChatNotificationListener>();
|
|
114
|
+
private readonly chatChannelStateListeners =
|
|
115
|
+
new Set<ChatChannelStateListener>();
|
|
111
116
|
private readonly chatPresenceListeners = new Set<ChatPresenceListener>();
|
|
112
117
|
private readonly chatReactionsListeners = new Set<ChatReactionsListener>();
|
|
113
118
|
private readonly teamNotificationListeners =
|
|
@@ -249,6 +254,17 @@ export class CloudApiSocket {
|
|
|
249
254
|
};
|
|
250
255
|
}
|
|
251
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The reader's read state or mute changed on another device or window. Like
|
|
259
|
+
* notifications, it never opens the socket by itself.
|
|
260
|
+
*/
|
|
261
|
+
subscribeChatChannelState(listener: ChatChannelStateListener): () => void {
|
|
262
|
+
this.chatChannelStateListeners.add(listener);
|
|
263
|
+
return () => {
|
|
264
|
+
this.chatChannelStateListeners.delete(listener);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
252
268
|
/** A message's reactions changed in a channel this socket reads. */
|
|
253
269
|
subscribeChatReactions(listener: ChatReactionsListener): () => void {
|
|
254
270
|
this.chatReactionsListeners.add(listener);
|
|
@@ -511,6 +527,7 @@ export class CloudApiSocket {
|
|
|
511
527
|
this.offeredFeatureListeners.clear();
|
|
512
528
|
this.connectionListeners.clear();
|
|
513
529
|
this.chatNotificationListeners.clear();
|
|
530
|
+
this.chatChannelStateListeners.clear();
|
|
514
531
|
this.chatPresenceListeners.clear();
|
|
515
532
|
this.chatReactionsListeners.clear();
|
|
516
533
|
this.teamNotificationListeners.clear();
|
|
@@ -614,6 +631,16 @@ export class CloudApiSocket {
|
|
|
614
631
|
return;
|
|
615
632
|
}
|
|
616
633
|
|
|
634
|
+
if (parsed?.type === "chat.channel_state") {
|
|
635
|
+
const state = normalizeChatChannelStateFrame(parsed.data);
|
|
636
|
+
if (state) {
|
|
637
|
+
for (const listener of this.chatChannelStateListeners) {
|
|
638
|
+
listener(state);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
617
644
|
if (parsed?.type === "team.notification" && parsed.data) {
|
|
618
645
|
const notification = normalizeTeamNotification(
|
|
619
646
|
parsed.data as TeamNotification,
|
|
@@ -107,6 +107,10 @@ export function useAppStartupRuntime({
|
|
|
107
107
|
appLog.info("app activity propagated", { active: appActive });
|
|
108
108
|
}, [appActive]);
|
|
109
109
|
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
chatController.setRaisesBackgroundAlerts(!isDetachedWindow);
|
|
112
|
+
}, [isDetachedWindow]);
|
|
113
|
+
|
|
110
114
|
// Runs for the life of the app: a freeze is only reportable if something
|
|
111
115
|
// was watching the clock while it happened.
|
|
112
116
|
useEffect(() => startMainThreadStallMonitor(), []);
|
|
@@ -749,7 +749,7 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
|
|
|
749
749
|
bloomberg: [],
|
|
750
750
|
},
|
|
751
751
|
FUT: {
|
|
752
|
-
summary: "Each root's most active futures contract across equity index, rates, energy, metals, agriculture, livestock and FX with last price, unit and session change, grouped by sector.",
|
|
752
|
+
summary: "Each root's most active futures contract across equity index, equity sectors, rates, energy, metals, agriculture, livestock and FX with last price, unit and session change, grouped by sector.",
|
|
753
753
|
usage: ["FUT"],
|
|
754
754
|
keys: [SEARCH, OPEN],
|
|
755
755
|
data: same(DELAYED),
|
package/src/components/index.ts
CHANGED
|
@@ -147,7 +147,7 @@ export type {
|
|
|
147
147
|
NoticeProps, NumberFieldProps, PaneStatusBodyProps, PopoverProps, ProseProps, QueryBarFilter,
|
|
148
148
|
QueryBarMultiFilter, QueryBarProps, QueryBarSearch, QueryBarSearchFocus, QueryBarSelectFilter, QueryBarTextFilter,
|
|
149
149
|
QueryBarToggleFilter, QueryBarView, RangeTrackProps, RatioBarProps, SectionHeadingProps, SectionProps, SegmentedControlProps,
|
|
150
|
-
SelectButtonOption, SelectButtonProps, SelectControl, StatGridProps, StatItem, TabsProps,
|
|
150
|
+
SelectButtonOption, SelectButtonProps, SelectControl, StatGridProps, StatItem, StatusRetry, TabsProps,
|
|
151
151
|
TextFieldProps, TextPromptDialogProps,
|
|
152
152
|
} from "./ui";
|
|
153
153
|
export { usePaneTicker } from "../state/app/context";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
2
2
|
import { Box, Text, TextAttributes, useUiCapabilities, type BoxRenderable } from "../../ui";
|
|
3
3
|
import { useLiveStretch } from "../../ui/live-stretch";
|
|
4
|
+
import { useSettledLayoutSize } from "../../ui/resize-settle";
|
|
4
5
|
import { blendHex } from "../../theme/colors";
|
|
5
6
|
import {
|
|
6
7
|
heatmapColorDistance,
|
|
@@ -91,9 +92,57 @@ export function buildHeatTreemapScene<T>(
|
|
|
91
92
|
return buildMetricTreemapScene(items, canvas.width, canvas.height, canvas.options, previous);
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
export interface HeatTreemapLayout<T> {
|
|
96
|
+
scene: MetricTreemapScene<T>;
|
|
97
|
+
/** The box is being resized (`useSettledLayoutSize`): hand it to the surface, which then holds still. */
|
|
98
|
+
resizing: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ShownScene<T> {
|
|
102
|
+
key: string;
|
|
103
|
+
canvas: HeatTreemapCanvas;
|
|
104
|
+
items: readonly MetricTreemapItem<T>[];
|
|
105
|
+
scene: MetricTreemapScene<T>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The scene a heat map shows in a `width` by `height` box (cells). Each
|
|
110
|
+
* layout is seeded with the scene on screen under the same `key` (a tab), so
|
|
111
|
+
* a refresh nudges tiles instead of reshuffling the board. On the desktop,
|
|
112
|
+
* while the box is being resized the scene on screen stretches with it and a
|
|
113
|
+
* refresh's new sizes wait: the board is laid out once, at the size the box
|
|
114
|
+
* settles on.
|
|
115
|
+
*/
|
|
116
|
+
export function useHeatTreemapScene<T>(
|
|
117
|
+
items: readonly MetricTreemapItem<T>[],
|
|
118
|
+
width: number,
|
|
119
|
+
height: number,
|
|
120
|
+
key = "",
|
|
121
|
+
): HeatTreemapLayout<T> {
|
|
122
|
+
const { nativePaneChrome, cellWidthPx, cellHeightPx } = useUiCapabilities();
|
|
123
|
+
const layout = useSettledLayoutSize(width, height, { enabled: nativePaneChrome === true });
|
|
124
|
+
const canvas = useMemo(
|
|
125
|
+
() => heatTreemapCanvas(layout.width, layout.height, { nativePaneChrome, cellWidthPx, cellHeightPx }),
|
|
126
|
+
[cellHeightPx, cellWidthPx, layout.height, layout.width, nativePaneChrome],
|
|
127
|
+
);
|
|
128
|
+
const shownRef = useRef<ShownScene<T> | null>(null);
|
|
129
|
+
const scene = useMemo(() => {
|
|
130
|
+
const shown = shownRef.current?.key === key ? shownRef.current : null;
|
|
131
|
+
// While the box resizes, the scene on screen holds until it is laid out
|
|
132
|
+
// again, a refresh's new sizes with it: the board relays out once.
|
|
133
|
+
if (shown && shown.canvas === canvas && (layout.resizing || shown.items === items)) return shown.scene;
|
|
134
|
+
const next = buildHeatTreemapScene(items, canvas, shown?.scene);
|
|
135
|
+
shownRef.current = { key, canvas, items, scene: next };
|
|
136
|
+
return next;
|
|
137
|
+
}, [canvas, items, key, layout.resizing]);
|
|
138
|
+
return { scene, resizing: layout.resizing };
|
|
139
|
+
}
|
|
140
|
+
|
|
94
141
|
export interface HeatTreemapSurfaceProps<T> {
|
|
95
|
-
/** Geometry, from `
|
|
142
|
+
/** Geometry, from `useHeatTreemapScene`; rebuilt only when sizes change. */
|
|
96
143
|
scene: MetricTreemapScene<T>;
|
|
144
|
+
/** From `useHeatTreemapScene`: while the box resizes, no tile glides, fades or pulses. */
|
|
145
|
+
resizing: boolean;
|
|
97
146
|
/** Current colours and text, by id; they change with every tick. */
|
|
98
147
|
items: readonly MetricTreemapItem<T>[];
|
|
99
148
|
width: number;
|
|
@@ -223,7 +272,7 @@ const VERTICAL_LABEL_STYLE: CSSProperties = {
|
|
|
223
272
|
|
|
224
273
|
const DomHeatTile = memo(function DomHeatTile({
|
|
225
274
|
id, rect, canvasWidth, canvasHeight, gap, label, value, valueSuffix, background, foreground,
|
|
226
|
-
selected, reducedMotion, glide, pulseCount, events,
|
|
275
|
+
selected, reducedMotion, resizing, glide, pulseCount, events,
|
|
227
276
|
}: {
|
|
228
277
|
id: string;
|
|
229
278
|
rect: TreemapRect;
|
|
@@ -237,6 +286,8 @@ const DomHeatTile = memo(function DomHeatTile({
|
|
|
237
286
|
foreground: string;
|
|
238
287
|
selected: boolean;
|
|
239
288
|
reducedMotion: boolean;
|
|
289
|
+
resizing: boolean;
|
|
290
|
+
/** A relayout this render may slide the tile: false for one that came with a new size. */
|
|
240
291
|
glide: boolean;
|
|
241
292
|
pulseCount: number;
|
|
242
293
|
events: SurfaceEvents;
|
|
@@ -248,16 +299,22 @@ const DomHeatTile = memo(function DomHeatTile({
|
|
|
248
299
|
});
|
|
249
300
|
// Where this tile sat before the last relayout, kept to slide it from there.
|
|
250
301
|
const placedRef = useRef(rect);
|
|
251
|
-
const glideRef = useRef<{ dx: number; dy: number; count: number }>({ dx: 0, dy: 0, count: 0 });
|
|
302
|
+
const glideRef = useRef<{ dx: number; dy: number; count: number; playing: boolean }>({ dx: 0, dy: 0, count: 0, playing: false });
|
|
303
|
+
const allowGlide = glide && !resizing;
|
|
252
304
|
if (placedRef.current !== rect) {
|
|
253
305
|
const dx = placedRef.current.x - rect.x;
|
|
254
306
|
const dy = placedRef.current.y - rect.y;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
307
|
+
// A slide belongs to the relayout that moved the tile: a later render
|
|
308
|
+
// (a tick, a hover, the end of a resize) never plays it again.
|
|
309
|
+
glideRef.current = allowGlide && (Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5)
|
|
310
|
+
? { dx, dy, count: glideRef.current.count + 1, playing: true }
|
|
311
|
+
: { ...glideRef.current, playing: false };
|
|
258
312
|
placedRef.current = rect;
|
|
313
|
+
} else if (resizing && glideRef.current.playing) {
|
|
314
|
+
// A resize stops a slide under way: the tile sits where its layout has it.
|
|
315
|
+
glideRef.current = { ...glideRef.current, playing: false };
|
|
259
316
|
}
|
|
260
|
-
const motion = heatTileMotion({ reducedMotion, glide, glideCount: glideRef.current.count, pulseCount });
|
|
317
|
+
const motion = heatTileMotion({ reducedMotion, resizing, glide: glideRef.current.playing, glideCount: glideRef.current.count, pulseCount });
|
|
261
318
|
const colors = useThemeColors();
|
|
262
319
|
const ring = resolveHeatmapSelectionRing(colors);
|
|
263
320
|
// The colour this tile showed before, kept to fade out over the new one.
|
|
@@ -274,10 +331,13 @@ const DomHeatTile = memo(function DomHeatTile({
|
|
|
274
331
|
else if (visible) fadeRef.current = { from: shownRef.current, key: (fadeRef.current?.key ?? 0) + 1 };
|
|
275
332
|
shownRef.current = background;
|
|
276
333
|
}
|
|
334
|
+
// A resize ends a fade under way, and the pulses due meanwhile are dropped, not saved for later.
|
|
335
|
+
if (resizing) fadeRef.current = null;
|
|
277
336
|
const fade = fadeRef.current;
|
|
278
337
|
// A finished overlay leaves the DOM, so idle tiles carry no extra nodes.
|
|
279
338
|
const [, setOverlayVersion] = useState(0);
|
|
280
339
|
const pulseDoneRef = useRef(0);
|
|
340
|
+
if (resizing) pulseDoneRef.current = Math.max(pulseDoneRef.current, pulseCount);
|
|
281
341
|
const pulsing = motion.pulseAnimation != null && pulseCount > pulseDoneRef.current;
|
|
282
342
|
const style: CSSProperties = {
|
|
283
343
|
position: "absolute",
|
|
@@ -491,7 +551,7 @@ function DomTooltip<T>({ state, itemsById, otherLines }: {
|
|
|
491
551
|
const EMPTY_PULSES: ReadonlyMap<string, number> = new Map();
|
|
492
552
|
|
|
493
553
|
function DomHeatTreemap<T>({
|
|
494
|
-
scene, items, width, height, selectedId, onSelect, onActivate, otherTooltip, otherSummary: summarizeOther,
|
|
554
|
+
scene, resizing, items, width, height, selectedId, onSelect, onActivate, otherTooltip, otherSummary: summarizeOther,
|
|
495
555
|
}: HeatTreemapSurfaceProps<T>) {
|
|
496
556
|
const reducedMotion = usePrefersReducedMotion();
|
|
497
557
|
const colors = useThemeColors();
|
|
@@ -519,13 +579,14 @@ function DomHeatTreemap<T>({
|
|
|
519
579
|
);
|
|
520
580
|
}, [itemsById, reducedMotion, scene]);
|
|
521
581
|
|
|
522
|
-
//
|
|
582
|
+
// The layout a resize settles on comes in at once, as a chart or a table
|
|
583
|
+
// redraws at its new size: tiles slide only when sizes move at rest.
|
|
523
584
|
const sizeKey = `${scene.width}x${scene.height}`;
|
|
524
|
-
const
|
|
525
|
-
const glide =
|
|
585
|
+
const restedRef = useRef({ sizeKey, resizing });
|
|
586
|
+
const glide = !resizing && !restedRef.current.resizing && restedRef.current.sizeKey === sizeKey;
|
|
526
587
|
useEffect(() => {
|
|
527
|
-
|
|
528
|
-
}, [sizeKey]);
|
|
588
|
+
restedRef.current = { sizeKey, resizing };
|
|
589
|
+
}, [resizing, sizeKey]);
|
|
529
590
|
|
|
530
591
|
const gap = scene.width < NARROW_CANVAS_PX ? 0.5 : 1;
|
|
531
592
|
const otherLines = useMemo(
|
|
@@ -603,6 +664,7 @@ function DomHeatTreemap<T>({
|
|
|
603
664
|
foreground={tileColors.foreground}
|
|
604
665
|
selected={tile.item.id === selectedId}
|
|
605
666
|
reducedMotion={reducedMotion}
|
|
667
|
+
resizing={resizing}
|
|
606
668
|
glide={glide}
|
|
607
669
|
pulseCount={pulses.get(tile.item.id) ?? 0}
|
|
608
670
|
events={events}
|
|
@@ -755,7 +817,7 @@ function TerminalHeatTreemap<T>({
|
|
|
755
817
|
* calm tiles (ticker, then the move, as space allows), a continuous scale
|
|
756
818
|
* from the theme's down colour to its up colour, and on the desktop a hover
|
|
757
819
|
* tooltip, colour fades, a short pulse on a tile whose move jumps and a glide
|
|
758
|
-
* when sizes change.
|
|
820
|
+
* when sizes change, none of them while the box is being resized.
|
|
759
821
|
*/
|
|
760
822
|
export function HeatTreemapSurface<T>(props: HeatTreemapSurfaceProps<T>) {
|
|
761
823
|
const { nativePaneChrome } = useUiCapabilities();
|
|
@@ -240,15 +240,17 @@ export interface HeatTileMotion {
|
|
|
240
240
|
* tile from its old place (its new size applies at once), a colour change
|
|
241
241
|
* fades the old colour out over the new one, and a jump in the move flashes
|
|
242
242
|
* once. `glideCount` alternates two keyframe names, which restarts the slide.
|
|
243
|
-
* Reduced motion keeps every change instant
|
|
243
|
+
* Reduced motion keeps every change instant, and so does a resize: while the
|
|
244
|
+
* box is changing size nothing on the board moves on its own.
|
|
244
245
|
*/
|
|
245
|
-
export function heatTileMotion({ reducedMotion, glide, glideCount, pulseCount }: {
|
|
246
|
+
export function heatTileMotion({ reducedMotion, resizing, glide, glideCount, pulseCount }: {
|
|
246
247
|
reducedMotion: boolean;
|
|
248
|
+
resizing: boolean;
|
|
247
249
|
glide: boolean;
|
|
248
250
|
glideCount: number;
|
|
249
251
|
pulseCount: number;
|
|
250
252
|
}): HeatTileMotion {
|
|
251
|
-
if (reducedMotion) return {};
|
|
253
|
+
if (reducedMotion || resizing) return {};
|
|
252
254
|
return {
|
|
253
255
|
...(glide && glideCount > 0
|
|
254
256
|
? { glideAnimation: `gloom-heat-glide-${glideCount % 2 === 0 ? "b" : "a"} ${GLIDE_MS}ms ease` }
|
|
@@ -18,7 +18,7 @@ export {
|
|
|
18
18
|
type MetricTreemapDirection,
|
|
19
19
|
type MetricTreemapItem,
|
|
20
20
|
} from "./layout";
|
|
21
|
-
export { buildHeatTreemapScene, heatTreemapCanvas, HeatTreemapSurface } from "./heat-surface";
|
|
21
|
+
export { buildHeatTreemapScene, heatTreemapCanvas, HeatTreemapSurface, useHeatTreemapScene } from "./heat-surface";
|
|
22
22
|
export { formatHeatTileMove, settleTreemapLayoutItems } from "./heat-tiles";
|
|
23
23
|
|
|
24
24
|
type PreventableMouseEvent = { preventDefault(): void };
|
|
@@ -26,6 +26,14 @@ export interface ButtonProps {
|
|
|
26
26
|
onPress?: () => void;
|
|
27
27
|
variant?: ButtonVariant;
|
|
28
28
|
disabled?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The action it started is still running (a retry waiting for its answer).
|
|
31
|
+
* It reads as disabled and ignores presses, but keeps its key, so it does not
|
|
32
|
+
* change size and the key does not fall through to something else.
|
|
33
|
+
*/
|
|
34
|
+
pending?: boolean;
|
|
35
|
+
/** What it says while pending, such as "Retrying...". Without it the label stays. */
|
|
36
|
+
pendingLabel?: string;
|
|
29
37
|
active?: boolean;
|
|
30
38
|
shortcut?: string;
|
|
31
39
|
/**
|
|
@@ -86,9 +94,11 @@ export function Button({
|
|
|
86
94
|
displayLabel,
|
|
87
95
|
children,
|
|
88
96
|
expanded,
|
|
89
|
-
onPress,
|
|
97
|
+
onPress: pressAction,
|
|
90
98
|
variant = "secondary",
|
|
91
99
|
disabled = false,
|
|
100
|
+
pending = false,
|
|
101
|
+
pendingLabel,
|
|
92
102
|
active = false,
|
|
93
103
|
shortcut,
|
|
94
104
|
desktopShortcut,
|
|
@@ -100,17 +110,21 @@ export function Button({
|
|
|
100
110
|
title,
|
|
101
111
|
}: ButtonProps) {
|
|
102
112
|
const colors = useThemeColors();
|
|
103
|
-
const label = t(rawLabel);
|
|
113
|
+
const label = t(pending && pendingLabel ? pendingLabel : rawLabel);
|
|
114
|
+
if (pending && pendingLabel) displayLabel = undefined;
|
|
115
|
+
// A pending press is ignored, but the button stays its scope's primary action.
|
|
116
|
+
const onPress = pressAction && pending ? () => {} : pressAction;
|
|
104
117
|
const scopedShortcut = useScopedButtonAction(label, onPress, disabled);
|
|
105
118
|
shortcut = shortcut ?? scopedShortcut;
|
|
119
|
+
const inert = disabled || pending;
|
|
106
120
|
const remoteScope = useRemoteUiScope();
|
|
107
121
|
useRemoteUiNode({
|
|
108
122
|
role: "button",
|
|
109
123
|
label,
|
|
110
|
-
disabled,
|
|
124
|
+
disabled: inert,
|
|
111
125
|
actions: {
|
|
112
126
|
press: () => {
|
|
113
|
-
if (!
|
|
127
|
+
if (!inert) onPress?.();
|
|
114
128
|
},
|
|
115
129
|
},
|
|
116
130
|
metadata: { variant, active, shortcut, expanded, ...(remoteScope ? { scope: remoteScope } : {}) },
|
|
@@ -124,7 +138,8 @@ export function Button({
|
|
|
124
138
|
expanded={expanded}
|
|
125
139
|
onPress={onPress}
|
|
126
140
|
variant={variant}
|
|
127
|
-
disabled={
|
|
141
|
+
disabled={inert}
|
|
142
|
+
pending={pending}
|
|
128
143
|
active={active}
|
|
129
144
|
shortcut={shortcut ?? desktopShortcut}
|
|
130
145
|
title={title}
|
|
@@ -137,7 +152,7 @@ export function Button({
|
|
|
137
152
|
);
|
|
138
153
|
}
|
|
139
154
|
|
|
140
|
-
const palette = resolveButtonColors(variant, active,
|
|
155
|
+
const palette = resolveButtonColors(variant, active, inert, colors);
|
|
141
156
|
|
|
142
157
|
return (
|
|
143
158
|
<Box
|
|
@@ -145,20 +160,20 @@ export function Button({
|
|
|
145
160
|
height={height ?? 1}
|
|
146
161
|
flexDirection="row"
|
|
147
162
|
backgroundColor={palette.bg}
|
|
148
|
-
cursor={
|
|
163
|
+
cursor={inert ? "default" : "pointer"}
|
|
149
164
|
onMouseDown={(event: { preventDefault?: () => void; stopPropagation?: () => void }) => {
|
|
150
165
|
if (stopPropagation) {
|
|
151
166
|
event.preventDefault?.();
|
|
152
167
|
event.stopPropagation?.();
|
|
153
168
|
}
|
|
154
|
-
if (!
|
|
169
|
+
if (!inert) onPress?.();
|
|
155
170
|
}}
|
|
156
171
|
>
|
|
157
172
|
{children ?? <Text fg={palette.fg} attributes={active ? TextAttributes.BOLD : 0}>
|
|
158
173
|
{compact ? displayLabel ?? label : ` ${displayLabel ?? label} `}
|
|
159
174
|
</Text>}
|
|
160
175
|
{shortcut && (
|
|
161
|
-
<Text fg={
|
|
176
|
+
<Text fg={inert ? colors.textMuted : colors.textDim}>
|
|
162
177
|
{` ${shortcut}`}
|
|
163
178
|
</Text>
|
|
164
179
|
)}
|
|
@@ -60,7 +60,7 @@ export type { TabsProps } from "./tabs";
|
|
|
60
60
|
export { SegmentedControl } from "./toggle";
|
|
61
61
|
export type { SegmentedControlProps } from "./toggle";
|
|
62
62
|
export { EmptyState, PaneStatusBody, Notice, loadingText, unavailableText } from "./status";
|
|
63
|
-
export type { EmptyStateProps, PaneStatusBodyProps, NoticeProps } from "./status";
|
|
63
|
+
export type { EmptyStateProps, PaneStatusBodyProps, NoticeProps, StatusRetry } from "./status";
|
|
64
64
|
export { Badge, BulletList, Divider, FigureList, KeyValueRow, Prose, READING_WIDTH, Section, SectionHeading } from "./display";
|
|
65
65
|
export type {
|
|
66
66
|
BadgeProps, BulletListProps, DividerProps, FigureListItem, FigureListProps, KeyValueRowProps, ProseProps, SectionProps,
|