gloomberb 0.6.0 → 0.6.1

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.
@@ -14,7 +14,7 @@ import {
14
14
  import { useQuoteStreaming } from "../../../state/use-quote-streaming";
15
15
  import { getCollectionName, getCollectionTickerCount } from "../../../state/selectors";
16
16
  import { getSharedRegistry } from "../../registry";
17
- import { EmptyState, Tabs } from "../../../components";
17
+ import { EmptyState, PaneFooterScope, Tabs } from "../../../components";
18
18
  import { resolveOptionsTarget } from "../../../utils/options";
19
19
  import { ChartTab } from "./chart-tab";
20
20
  import { ResolvedFinancialsTab } from "./financials-tab";
@@ -210,12 +210,14 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
210
210
  height={contentHeight}
211
211
  overflow="hidden"
212
212
  >
213
- <OverviewTab
214
- width={width}
215
- symbol={symbol}
216
- ticker={ticker}
217
- financials={financials}
218
- />
213
+ <PaneFooterScope active={resolvedTabId === "overview"}>
214
+ <OverviewTab
215
+ width={width}
216
+ symbol={symbol}
217
+ ticker={ticker}
218
+ financials={financials}
219
+ />
220
+ </PaneFooterScope>
219
221
  </Box>
220
222
  )}
221
223
  {renderedTabIds.has("financials") && (
@@ -228,10 +230,12 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
228
230
  height={contentHeight}
229
231
  overflow="hidden"
230
232
  >
231
- <ResolvedFinancialsTab
232
- focused={focused && resolvedTabId === "financials"}
233
- financials={financials}
234
- />
233
+ <PaneFooterScope active={resolvedTabId === "financials"}>
234
+ <ResolvedFinancialsTab
235
+ focused={focused && resolvedTabId === "financials"}
236
+ financials={financials}
237
+ />
238
+ </PaneFooterScope>
235
239
  </Box>
236
240
  )}
237
241
  {renderedTabIds.has("chart") && (
@@ -244,17 +248,19 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
244
248
  height={contentHeight}
245
249
  overflow="hidden"
246
250
  >
247
- <ChartTab
248
- width={width}
249
- height={contentHeight}
250
- focused={focused && resolvedTabId === "chart"}
251
- interactive={chartInteractive}
252
- axisMode={paneSettings.chartAxisMode}
253
- onActivate={() => setChartInteractiveEager(true)}
254
- symbol={symbol}
255
- ticker={ticker}
256
- financials={financials}
257
- />
251
+ <PaneFooterScope active={resolvedTabId === "chart"}>
252
+ <ChartTab
253
+ width={width}
254
+ height={contentHeight}
255
+ focused={focused && resolvedTabId === "chart"}
256
+ interactive={chartInteractive}
257
+ axisMode={paneSettings.chartAxisMode}
258
+ onActivate={() => setChartInteractiveEager(true)}
259
+ symbol={symbol}
260
+ ticker={ticker}
261
+ financials={financials}
262
+ />
263
+ </PaneFooterScope>
258
264
  </Box>
259
265
  )}
260
266
 
@@ -272,12 +278,14 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
272
278
  height={contentHeight}
273
279
  overflow="hidden"
274
280
  >
275
- <PluginTab
276
- width={width}
277
- height={contentHeight}
278
- focused={focused && isActive}
279
- onCapture={isActive ? handlePluginCapture : ignorePluginCapture}
280
- />
281
+ <PaneFooterScope active={isActive}>
282
+ <PluginTab
283
+ width={width}
284
+ height={contentHeight}
285
+ focused={focused && isActive}
286
+ onCapture={isActive ? handlePluginCapture : ignorePluginCapture}
287
+ />
288
+ </PaneFooterScope>
281
289
  </Box>
282
290
  );
283
291
  })}
@@ -65,6 +65,15 @@ function makeFinancials(length: number): TickerFinancials {
65
65
  };
66
66
  }
67
67
 
68
+ function makeFinancialsWithStatements(length: number): TickerFinancials {
69
+ const financials = makeFinancials(length);
70
+ financials.annualStatements = [
71
+ { date: "2023-12-31", totalRevenue: 90, netIncome: 10 },
72
+ { date: "2024-12-31", totalRevenue: 110, netIncome: 14 },
73
+ ];
74
+ return financials;
75
+ }
76
+
68
77
  function makeDetailConfig(symbol: string): AppConfig {
69
78
  const config = createDefaultConfig("/tmp/gloomberb-test");
70
79
  config.chartPreferences.renderer = "kitty";
@@ -130,6 +139,13 @@ async function flushFrames(count = 3) {
130
139
  }
131
140
  }
132
141
 
142
+ function getSurfaceVisibleRect(
143
+ manager: { surfaces: Map<string, { snapshot: { visibleRect: unknown } }> },
144
+ surfaceId: string,
145
+ ): unknown {
146
+ return manager.surfaces.get(surfaceId)?.snapshot.visibleRect ?? null;
147
+ }
148
+
133
149
  afterEach(() => {
134
150
  harnessDispatch = null;
135
151
  if (root) {
@@ -201,4 +217,54 @@ describe("Ticker detail chart tab switching", () => {
201
217
  expect(returnedOverview).toContain("AAPL");
202
218
  expect(manager.surfaces.has("chart-surface:ticker-detail:test:compact:base")).toBe(true);
203
219
  });
220
+
221
+ test("hides the full chart kitty surface when switching to financials", async () => {
222
+ const symbol = "AAPL";
223
+ const config = makeDetailConfig(symbol);
224
+
225
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
226
+ testSetup = await createTestRenderer({ width: 120, height: 36 });
227
+ (testSetup.renderer as { _capabilities: unknown })._capabilities = { kitty_graphics: true };
228
+ (testSetup.renderer as { _resolution: unknown })._resolution = { width: 1200, height: 960 };
229
+
230
+ root = createRoot(testSetup.renderer);
231
+ act(() => {
232
+ root!.render(
233
+ <DetailHarness
234
+ config={config}
235
+ ticker={makeTicker(symbol)}
236
+ financials={makeFinancialsWithStatements(48)}
237
+ />,
238
+ );
239
+ });
240
+
241
+ await flushFrames();
242
+ const manager = getNativeSurfaceManager(testSetup.renderer as never) as unknown as {
243
+ surfaces: Map<string, { snapshot: { visibleRect: unknown } }>;
244
+ };
245
+ const fullSurfaceId = "chart-surface:ticker-detail:test:full:base";
246
+
247
+ act(() => {
248
+ harnessDispatch!({
249
+ type: "UPDATE_PANE_STATE",
250
+ paneId: TEST_PANE_ID,
251
+ patch: { activeTabId: "chart" },
252
+ });
253
+ });
254
+
255
+ await flushFrames();
256
+ expect(getSurfaceVisibleRect(manager, fullSurfaceId)).not.toBeNull();
257
+
258
+ act(() => {
259
+ harnessDispatch!({
260
+ type: "UPDATE_PANE_STATE",
261
+ paneId: TEST_PANE_ID,
262
+ patch: { activeTabId: "financials" },
263
+ });
264
+ });
265
+
266
+ await flushFrames();
267
+ expect(testSetup.captureCharFrame()).toContain("Income");
268
+ expect(getSurfaceVisibleRect(manager, fullSurfaceId)).toBeNull();
269
+ });
204
270
  });
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useReducer, type ReactElement } from "react";
3
3
  import { testRender } from "../../renderers/opentui/test-utils";
4
4
  import type { ScrollBoxRenderable } from "@opentui/core";
5
+ import { Box } from "../../ui";
5
6
  import {
6
7
  AppContext,
7
8
  appReducer,
@@ -22,6 +23,7 @@ import type { DataProvider } from "../../types/data-provider";
22
23
  import type { TickerFinancials } from "../../types/financials";
23
24
  import type { DetailTabDef } from "../../types/plugin";
24
25
  import type { TickerRecord } from "../../types/ticker";
26
+ import { PaneFooterBar, PaneFooterProvider, usePaneFooter } from "../../components/layout/pane-footer";
25
27
  import type { PluginRegistry } from "../registry";
26
28
  import { setSharedRegistryForTests } from "../registry";
27
29
  import { PluginRenderProvider } from "../plugin-runtime";
@@ -153,6 +155,34 @@ function makeRegistry(): PluginRegistry {
153
155
  return { detailTabs } as unknown as PluginRegistry;
154
156
  }
155
157
 
158
+ function createFooterProbeTab(tabId: string, label: string): DetailTabDef["component"] {
159
+ return function FooterProbeTab() {
160
+ usePaneFooter(`footer-probe:${tabId}`, () => ({
161
+ info: [{ id: `${tabId}:info`, parts: [{ text: `${tabId}-footer`, tone: "value" }] }],
162
+ hints: [{ id: `${tabId}:hint`, key: label[0]!.toLowerCase(), label: `${label.slice(1).toLowerCase()}-hint` }],
163
+ }), []);
164
+ return <text>{`${label} body`}</text>;
165
+ };
166
+ }
167
+
168
+ function makeFooterRegistry(): PluginRegistry {
169
+ const detailTabs = new Map<string, DetailTabDef>([
170
+ ["probe-alpha", {
171
+ id: "probe-alpha",
172
+ name: "Alpha",
173
+ order: 25,
174
+ component: createFooterProbeTab("alpha", "Alpha"),
175
+ }],
176
+ ["probe-beta", {
177
+ id: "probe-beta",
178
+ name: "Beta",
179
+ order: 30,
180
+ component: createFooterProbeTab("beta", "Beta"),
181
+ }],
182
+ ]);
183
+ return { detailTabs } as unknown as PluginRegistry;
184
+ }
185
+
156
186
  function createGatewayInstance(id = "ibkr-paper"): BrokerInstanceConfig {
157
187
  return {
158
188
  id,
@@ -241,6 +271,40 @@ function DetailHarness({
241
271
  );
242
272
  }
243
273
 
274
+ function DetailFooterHarness({
275
+ config,
276
+ ticker,
277
+ financials,
278
+ activeTabId,
279
+ width = 90,
280
+ height = 18,
281
+ }: {
282
+ config: AppConfig;
283
+ ticker: TickerRecord;
284
+ financials: TickerFinancials | null;
285
+ activeTabId: string;
286
+ width?: number;
287
+ height?: number;
288
+ }) {
289
+ return (
290
+ <PaneFooterProvider>
291
+ {(footer) => (
292
+ <Box flexDirection="column" width={width} height={height}>
293
+ <DetailHarness
294
+ config={config}
295
+ ticker={ticker}
296
+ financials={financials}
297
+ activeTabId={activeTabId}
298
+ width={width}
299
+ height={height - 1}
300
+ />
301
+ <PaneFooterBar footer={footer} focused width={width} />
302
+ </Box>
303
+ )}
304
+ </PaneFooterProvider>
305
+ );
306
+ }
307
+
244
308
  async function flushFrame() {
245
309
  await act(async () => {
246
310
  await testSetup!.renderOnce();
@@ -745,4 +809,42 @@ describe("TickerDetailPane", () => {
745
809
  expect(frame).toContain("active:overview");
746
810
  expect(frame).not.toContain("Trade");
747
811
  });
812
+
813
+ test("shows only active mounted tab pane hints", async () => {
814
+ setSharedRegistryForTests(makeFooterRegistry());
815
+ setOptionsProvider(createProvider(false));
816
+
817
+ testSetup = await testRender(
818
+ <DetailFooterHarness
819
+ config={createDetailConfig("AAPL")}
820
+ ticker={makeTicker("AAPL")}
821
+ financials={null}
822
+ activeTabId="probe-alpha"
823
+ />,
824
+ { width: 90, height: 18 },
825
+ );
826
+
827
+ await flushFrame();
828
+ await flushFrame();
829
+
830
+ let frame = testSetup.captureCharFrame();
831
+ expect(frame).toContain("alpha-footer");
832
+ expect(frame).toContain("[a]lpha-hint");
833
+ expect(frame).not.toContain("beta-footer");
834
+ expect(frame).not.toContain("[b]eta-hint");
835
+
836
+ await act(async () => {
837
+ harnessDispatch!({ type: "UPDATE_PANE_STATE", paneId: TEST_PANE_ID, patch: { activeTabId: "probe-beta" } });
838
+ await Promise.resolve();
839
+ });
840
+ await flushFrame();
841
+ await flushFrame();
842
+
843
+ frame = testSetup.captureCharFrame();
844
+ expect(frame).toContain("Beta body");
845
+ expect(frame).toContain("beta-footer");
846
+ expect(frame).toContain("[b]eta-hint");
847
+ expect(frame).not.toContain("alpha-footer");
848
+ expect(frame).not.toContain("[a]lpha-hint");
849
+ });
748
850
  });
@@ -2,6 +2,8 @@ import { afterEach, expect, test } from "bun:test";
2
2
  import { DialogProvider } from "@opentui-ui/dialog/react";
3
3
  import { testRender } from "../../renderers/opentui/test-utils";
4
4
  import { act } from "react";
5
+ import { Box } from "../../ui";
6
+ import { PaneFooterBar, PaneFooterProvider } from "../../components/layout/pane-footer";
5
7
  import {
6
8
  AppContext,
7
9
  PaneInstanceProvider,
@@ -104,23 +106,37 @@ function TradeHarness({
104
106
  ticker,
105
107
  financials,
106
108
  brokerAccounts = {},
109
+ withFooter = false,
107
110
  }: {
108
111
  config: AppConfig;
109
112
  ticker: TickerRecord;
110
113
  financials: TickerFinancials;
111
114
  brokerAccounts?: Record<string, import("../../types/trading").BrokerAccount[]>;
115
+ withFooter?: boolean;
112
116
  }) {
113
117
  const state = createInitialState(config);
114
118
  state.focusedPaneId = TEST_PANE_ID;
115
119
  state.tickers = new Map([[ticker.metadata.ticker, ticker]]);
116
120
  state.financials = new Map([[ticker.metadata.ticker, financials]]);
117
121
  state.brokerAccounts = brokerAccounts;
122
+ const tradeTab = <TradeTab focused width={88} height={30} onCapture={() => {}} />;
118
123
 
119
124
  return (
120
125
  <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
121
126
  <AppContext value={{ state, dispatch: () => {} }}>
122
127
  <PaneInstanceProvider paneId={TEST_PANE_ID}>
123
- <TradeTab focused width={88} height={30} onCapture={() => {}} />
128
+ {withFooter
129
+ ? (
130
+ <PaneFooterProvider>
131
+ {(footer) => (
132
+ <Box flexDirection="column" width={88} height={31}>
133
+ {tradeTab}
134
+ <PaneFooterBar footer={footer} focused width={88} />
135
+ </Box>
136
+ )}
137
+ </PaneFooterProvider>
138
+ )
139
+ : tradeTab}
124
140
  </PaneInstanceProvider>
125
141
  </AppContext>
126
142
  </DialogProvider>
@@ -223,3 +239,39 @@ test("prefills the only cached IBKR account when the live gateway snapshot is em
223
239
  expect(frame).toContain("Account DU123456");
224
240
  expect(frame).toContain("Paper Gateway");
225
241
  });
242
+
243
+ test("omits redundant and disabled trade footer hints", async () => {
244
+ const config = createTradeConfig("AMD");
245
+ const ticker = makeTicker("AMD", "Advanced Micro Devices, Inc.");
246
+ const financials = makeFinancials();
247
+
248
+ clearTradingDraft();
249
+ stubGatewayRefresh();
250
+ prefillTradeFromTicker(ticker, "BUY");
251
+
252
+ await act(async () => {
253
+ testSetup = await testRender(
254
+ <TradeHarness config={config} ticker={ticker} financials={financials} withFooter />,
255
+ { width: 88, height: 31 },
256
+ );
257
+ });
258
+
259
+ await act(async () => {
260
+ await testSetup!.renderOnce();
261
+ });
262
+ await act(async () => {
263
+ await testSetup!.renderOnce();
264
+ });
265
+
266
+ const frame = testSetup.captureCharFrame();
267
+ expect(frame).toContain("[r]efresh");
268
+ expect(frame).toContain("[i]profile");
269
+ expect(frame).toContain("[a]ccount");
270
+ expect(frame).toContain("[b/v]side");
271
+ expect(frame).toContain("[q]ty");
272
+ expect(frame).toContain("[t]ype");
273
+ expect(frame).toContain("[p]review");
274
+ expect(frame).not.toContain("[s]ymbol");
275
+ expect(frame).not.toContain("[l]imit");
276
+ expect(frame).not.toContain("[x]stop");
277
+ });
@@ -695,21 +695,23 @@ export function TradeTab({ focused, width, onCapture }: DetailTabProps) {
695
695
  ...(statusText ? [{ id: "status", parts: [{ text: statusText, tone: statusTone === "negative" ? "negative" as const : statusTone === "positive" ? "positive" as const : "muted" as const }] }] : []),
696
696
  ],
697
697
  hints: [
698
- { id: "refresh", key: "r", label: "efresh", onPress: () => refresh().catch(() => {}), disabled: ticketState.busy },
699
- { id: "profile", key: "i", label: "profile", onPress: () => chooseBrokerInstance().catch(() => {}), disabled: ticketState.busy },
700
- { id: "instrument", key: "s", label: "ymbol", onPress: () => chooseInstrument().catch(() => {}), disabled: ticketState.busy },
701
- { id: "account", key: "a", label: "ccount", onPress: () => chooseAccount().catch(() => {}), disabled: ticketState.busy },
702
- { id: "side", key: "b/v", label: "side", onPress: symbol && ticker ? () => setTradeTicketDraft(symbol, { action: ticketState.draft.action === "BUY" ? "SELL" : "BUY" }, ticker) : undefined, disabled: ticketState.busy || !symbol || !ticker },
703
- { id: "quantity", key: "q", label: "ty", onPress: () => editNumericField("Quantity", ticketState.draft.quantity, (value) => { if (value != null && symbol && ticker) setTradeTicketDraft(symbol, { quantity: value }, ticker); }).catch(() => {}), disabled: ticketState.busy || !symbol || !ticker },
704
- { id: "type", key: "t", label: "ype", onPress: () => editOrderType().catch(() => {}), disabled: ticketState.busy },
705
- { id: "limit", key: "l", label: "imit", onPress: () => editPriceField("Limit Price", ticketState.draft.limitPrice, (value) => { if (symbol && ticker) setTradeTicketDraft(symbol, { limitPrice: value }, ticker); }).catch(() => {}), disabled: ticketState.busy || !showLimit || !symbol || !ticker },
706
- { id: "stop", key: "x", label: "stop", onPress: () => editPriceField("Stop Price", ticketState.draft.stopPrice, (value) => { if (symbol && ticker) setTradeTicketDraft(symbol, { stopPrice: value }, ticker); }).catch(() => {}), disabled: ticketState.busy || !showStop || !symbol || !ticker },
707
- { id: "preview", key: "p", label: "review", onPress: () => previewOrder().catch(() => {}), disabled: ticketState.busy || !symbol || !ticker },
698
+ ...(!ticketState.busy ? [
699
+ { id: "refresh", key: "r", label: "efresh", onPress: () => refresh().catch(() => {}) },
700
+ { id: "profile", key: "i", label: "profile", onPress: () => chooseBrokerInstance().catch(() => {}) },
701
+ { id: "account", key: "a", label: "ccount", onPress: () => chooseAccount().catch(() => {}) },
702
+ ] : []),
703
+ ...(!ticketState.busy && symbol && ticker ? [
704
+ { id: "side", key: "b/v", label: "side", onPress: () => setTradeTicketDraft(symbol, { action: ticketState.draft.action === "BUY" ? "SELL" : "BUY" }, ticker) },
705
+ { id: "quantity", key: "q", label: "ty", onPress: () => editNumericField("Quantity", ticketState.draft.quantity, (value) => { if (value != null) setTradeTicketDraft(symbol, { quantity: value }, ticker); }).catch(() => {}) },
706
+ { id: "type", key: "t", label: "ype", onPress: () => editOrderType().catch(() => {}) },
707
+ ...(showLimit ? [{ id: "limit", key: "l", label: "imit", onPress: () => editPriceField("Limit Price", ticketState.draft.limitPrice, (value) => { setTradeTicketDraft(symbol, { limitPrice: value }, ticker); }).catch(() => {}) }] : []),
708
+ ...(showStop ? [{ id: "stop", key: "x", label: "stop", onPress: () => editPriceField("Stop Price", ticketState.draft.stopPrice, (value) => { setTradeTicketDraft(symbol, { stopPrice: value }, ticker); }).catch(() => {}) }] : []),
709
+ { id: "preview", key: "p", label: "review", onPress: () => previewOrder().catch(() => {}) },
710
+ ] : []),
708
711
  ],
709
712
  }), [
710
713
  chooseAccount,
711
714
  chooseBrokerInstance,
712
- chooseInstrument,
713
715
  editNumericField,
714
716
  editOrderType,
715
717
  editPriceField,
@@ -9,14 +9,22 @@ export interface KeyEventLike {
9
9
  alt: boolean;
10
10
  meta: boolean;
11
11
  super?: boolean;
12
+ readonly defaultPrevented?: boolean;
13
+ readonly propagationStopped?: boolean;
12
14
  preventDefault(): void;
13
15
  stopPropagation(): void;
14
16
  }
15
17
 
18
+ export interface ShortcutOptions {
19
+ enabled?: boolean;
20
+ scope?: string;
21
+ phase?: "normal" | "after";
22
+ }
23
+
16
24
  export interface InputHost {
17
25
  useShortcut(
18
26
  handler: (event: KeyEventLike) => void,
19
- options?: { enabled?: boolean; scope?: string },
27
+ options?: ShortcutOptions,
20
28
  ): void;
21
29
  useViewport(): { width: number; height: number };
22
30
  }
@@ -43,7 +51,7 @@ function useInputHost(): InputHost {
43
51
 
44
52
  export function useShortcut(
45
53
  handler: (event: KeyEventLike) => void,
46
- options?: { enabled?: boolean; scope?: string },
54
+ options?: ShortcutOptions,
47
55
  ): void {
48
56
  useInputHost().useShortcut(handler, options);
49
57
  }
@@ -1,7 +1,7 @@
1
1
  /// <reference lib="dom" />
2
2
  /** @jsxImportSource react */
3
- import { useEffect, useMemo, useSyncExternalStore, type ReactNode } from "react";
4
- import { InputHostProvider, type InputHost, type KeyEventLike } from "../../../react/input";
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
4
+ import { InputHostProvider, type InputHost, type KeyEventLike, type ShortcutOptions } from "../../../react/input";
5
5
  import { hasWebCtrlModifier, normalizeWebKeyName, shouldConsumeWebAppKeyDown, webKeySequence } from "./key-event";
6
6
 
7
7
  export const WEB_CELL_WIDTH = 8;
@@ -9,6 +9,7 @@ export const WEB_CELL_HEIGHT = 18;
9
9
 
10
10
  function toKeyEventLike(event: KeyboardEvent): KeyEventLike {
11
11
  const key = normalizeWebKeyName(event.key);
12
+ let propagationStopped = false;
12
13
  return {
13
14
  key,
14
15
  name: key,
@@ -18,11 +19,29 @@ function toKeyEventLike(event: KeyboardEvent): KeyEventLike {
18
19
  alt: event.altKey,
19
20
  meta: event.metaKey,
20
21
  super: event.metaKey,
22
+ get defaultPrevented() {
23
+ return event.defaultPrevented;
24
+ },
25
+ get propagationStopped() {
26
+ return propagationStopped;
27
+ },
21
28
  preventDefault: () => event.preventDefault(),
22
- stopPropagation: () => event.stopPropagation(),
29
+ stopPropagation: () => {
30
+ propagationStopped = true;
31
+ event.stopPropagation();
32
+ },
23
33
  };
24
34
  }
25
35
 
36
+ interface ShortcutEntry {
37
+ handlerRef: { current: (event: KeyEventLike) => void };
38
+ enabledRef: { current: boolean };
39
+ phase: NonNullable<ShortcutOptions["phase"]>;
40
+ order: number;
41
+ }
42
+
43
+ let nextShortcutOrder = 1;
44
+
26
45
  function subscribeViewport(listener: () => void): () => void {
27
46
  window.addEventListener("resize", listener);
28
47
  return () => window.removeEventListener("resize", listener);
@@ -40,17 +59,45 @@ function getViewport() {
40
59
  }
41
60
 
42
61
  export function WebInputHostProvider({ children }: { children: ReactNode }) {
62
+ const shortcutsRef = useRef<ShortcutEntry[]>([]);
63
+
64
+ useEffect(() => {
65
+ const onKeyDown = (event: KeyboardEvent) => {
66
+ const shortcutEvent = toKeyEventLike(event);
67
+ for (const phase of ["normal", "after"] as const) {
68
+ if (phase === "after" && (shortcutEvent.defaultPrevented || shortcutEvent.propagationStopped)) break;
69
+ for (const entry of shortcutsRef.current) {
70
+ if (entry.phase !== phase || !entry.enabledRef.current) continue;
71
+ entry.handlerRef.current(shortcutEvent);
72
+ if (shortcutEvent.propagationStopped) break;
73
+ }
74
+ if (shortcutEvent.propagationStopped) break;
75
+ }
76
+ if (shouldConsumeWebAppKeyDown(event)) event.preventDefault();
77
+ };
78
+ window.addEventListener("keydown", onKeyDown);
79
+ return () => window.removeEventListener("keydown", onKeyDown);
80
+ }, []);
81
+
43
82
  const host = useMemo<InputHost>(() => ({
44
83
  useShortcut(handler, options) {
45
- useEffect(() => {
46
- if (options?.enabled === false) return;
47
- const onKeyDown = (event: KeyboardEvent) => {
48
- handler(toKeyEventLike(event));
49
- if (shouldConsumeWebAppKeyDown(event)) event.preventDefault();
84
+ const handlerRef = useRef(handler);
85
+ const enabledRef = useRef(options?.enabled !== false);
86
+ handlerRef.current = handler;
87
+ enabledRef.current = options?.enabled !== false;
88
+
89
+ useLayoutEffect(() => {
90
+ const entry: ShortcutEntry = {
91
+ handlerRef,
92
+ enabledRef,
93
+ phase: options?.phase ?? "normal",
94
+ order: nextShortcutOrder++,
95
+ };
96
+ shortcutsRef.current = [...shortcutsRef.current, entry].sort((a, b) => a.order - b.order);
97
+ return () => {
98
+ shortcutsRef.current = shortcutsRef.current.filter((current) => current !== entry);
50
99
  };
51
- window.addEventListener("keydown", onKeyDown);
52
- return () => window.removeEventListener("keydown", onKeyDown);
53
- }, [handler, options?.enabled, options?.scope]);
100
+ }, [options?.phase, options?.scope]);
54
101
  },
55
102
  useViewport() {
56
103
  return useSyncExternalStore(subscribeViewport, getViewport, getViewport);
@@ -176,7 +176,7 @@ function cleanDomProps(props: Record<string, unknown>): Record<string, unknown>
176
176
  "margin", "marginX", "marginY", "marginLeft", "marginRight", "marginTop", "marginBottom",
177
177
  "bold", "underline", "inverse", "dim", "italic", "strikethrough", "attributes", "content", "position", "left", "right", "top", "bottom",
178
178
  "zIndex", "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight", "gap",
179
- "border", "borderStyle", "borderColor", "overflow", "selectable", "name", "color",
179
+ "border", "borderStyle", "borderColor", "overflow", "selectable", "visible", "name", "color",
180
180
  "focused", "focusedBackgroundColor", "textColor", "focusedTextColor", "placeholderColor",
181
181
  "cursorColor", "selectionBg", "selectionFg", "showCursor", "keyBindings", "wrapText", "wrapMode",
182
182
  "initialValue", "value", "onInput", "onChange", "onSubmit", "onCursorChange", "onMouse",
@@ -445,7 +445,11 @@ const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { children?:
445
445
  onMouseUp={(event) => callMouseHandler(propsRef.current.onMouseUp, event, "up")}
446
446
  onMouseOut={handleMouseOut}
447
447
  onWheel={typeof props.onMouseScroll === "function" ? handleWheel : undefined}
448
- style={{ ...commonStyle(props), ...(props.style as CSSProperties | undefined) }}
448
+ style={{
449
+ ...commonStyle(props),
450
+ ...(props.style as CSSProperties | undefined),
451
+ ...(props.visible === false ? { display: "none" } : undefined),
452
+ }}
449
453
  >
450
454
  {children as ReactNode}
451
455
  </div>
@@ -956,6 +960,15 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
956
960
  height: toCellY(element?.clientHeight ?? 0),
957
961
  };
958
962
  },
963
+ getBoundingClientRect() {
964
+ const rect = getElement()?.getBoundingClientRect();
965
+ return {
966
+ x: rect?.x ?? 0,
967
+ y: rect?.y ?? 0,
968
+ width: rect?.width ?? 0,
969
+ height: rect?.height ?? 0,
970
+ };
971
+ },
959
972
  horizontalScrollBar,
960
973
  verticalScrollBar,
961
974
  scrollTo(target: number | { x?: number; y?: number }, y?: number) {
@@ -1008,7 +1021,13 @@ const WebScrollBox = forwardRef<ScrollBoxRenderable, Record<string, unknown> & {
1008
1021
  onMouseOut={(event) => callMouseHandler(props.onMouseOut, event, "out")}
1009
1022
  onScroll={scrollable ? handleScroll : undefined}
1010
1023
  onWheel={scrollable ? handleWheel : undefined}
1011
- style={{ ...commonStyle(props), overflowX, overflowY, ...(props.style as CSSProperties | undefined) }}
1024
+ style={{
1025
+ ...commonStyle(props),
1026
+ overflowX,
1027
+ overflowY,
1028
+ ...(props.style as CSSProperties | undefined),
1029
+ ...(props.visible === false ? { display: "none" } : undefined),
1030
+ }}
1012
1031
  >
1013
1032
  {children as ReactNode}
1014
1033
  </div>
@@ -38,6 +38,12 @@ export function toKeyEventLike(event: {
38
38
  alt: event.alt ?? false,
39
39
  meta: event.meta ?? event.super ?? false,
40
40
  super: event.super ?? event.meta ?? false,
41
+ get defaultPrevented() {
42
+ return event.defaultPrevented ?? false;
43
+ },
44
+ get propagationStopped() {
45
+ return event.propagationStopped ?? false;
46
+ },
41
47
  preventDefault: () => event.preventDefault?.(),
42
48
  stopPropagation: () => event.stopPropagation?.(),
43
49
  };