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.
- package/package.json +2 -1
- package/src/components/chart/comparison-stock-chart.tsx +14 -50
- package/src/components/chart/native/kitty.test.ts +23 -0
- package/src/components/chart/native/surface-visibility.ts +48 -0
- package/src/components/chart/stock-chart.tsx +14 -50
- package/src/components/index.ts +1 -1
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-footer.tsx +15 -0
- package/src/components/layout/pane-keyboard-scroll.test.tsx +226 -0
- package/src/components/ui/remote-image.tsx +15 -40
- package/src/plugins/builtin/ticker-detail/pane.tsx +36 -28
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +66 -0
- package/src/plugins/builtin/ticker-detail.test.tsx +102 -0
- package/src/plugins/ibkr/trade-tab.test.tsx +53 -1
- package/src/plugins/ibkr/trade-tab.tsx +13 -11
- package/src/react/input.ts +10 -2
- package/src/renderers/electrobun/view/input-host.tsx +58 -11
- package/src/renderers/electrobun/view/ui-host.tsx +22 -3
- package/src/renderers/opentui/host.tsx +6 -0
- package/src/renderers/opentui/image-loader.test.ts +25 -0
- package/src/renderers/opentui/image-loader.ts +105 -0
- package/src/renderers/opentui/image-surface.tsx +266 -0
- package/src/renderers/opentui/input-host.tsx +45 -6
- package/src/renderers/opentui/ui-host.tsx +2 -1
- package/src/state/app-context.tsx +4 -0
- package/src/state/pane-scroll-registry.tsx +254 -0
- package/src/ui/host.tsx +3 -0
- package/src/ui/index.tsx +9 -1
- package/src/version.ts +1 -1
|
@@ -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
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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,
|
package/src/react/input.ts
CHANGED
|
@@ -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?:
|
|
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?:
|
|
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: () =>
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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={{
|
|
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={{
|
|
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
|
};
|