gloomberb 0.7.0 → 0.7.2
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 +1 -1
- package/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +85 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +101 -5
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +14 -2
- package/src/ui/index.tsx +1 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { AppAction } from "../../state/app-context";
|
|
2
|
-
import { themes, getThemeIds } from "../../theme/themes";
|
|
3
2
|
|
|
4
3
|
export type CommandExecutor = (dispatch: React.Dispatch<AppAction>, context: CommandContext) => void | Promise<void>;
|
|
5
4
|
|
|
@@ -239,15 +238,6 @@ export const commands: Command[] = [
|
|
|
239
238
|
},
|
|
240
239
|
];
|
|
241
240
|
|
|
242
|
-
/** Get theme options for the command bar */
|
|
243
|
-
export function getThemeOptions(): Array<{ id: string; name: string; description: string }> {
|
|
244
|
-
return getThemeIds().map((id) => ({
|
|
245
|
-
id,
|
|
246
|
-
name: themes[id]!.name,
|
|
247
|
-
description: themes[id]!.description,
|
|
248
|
-
}));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
241
|
/** Find a command whose prefix matches the start of the input */
|
|
252
242
|
export function matchPrefix(input: string, commandList: Command[] = commands): { command: Command; arg: string } | null {
|
|
253
243
|
const upper = input.toUpperCase().trim();
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
} from "./workflow-types";
|
|
15
15
|
import type { CollectionKind, CollectionMembershipAction } from "./workflow-ops";
|
|
16
16
|
|
|
17
|
-
export type RouteCommandId = "security-description" | "
|
|
17
|
+
export type RouteCommandId = "security-description" | "plugins" | "layout";
|
|
18
18
|
export type CollectionCommandId = "add-watchlist" | "add-portfolio" | "remove-watchlist" | "remove-portfolio";
|
|
19
19
|
|
|
20
20
|
export function summarizeError(error: unknown): Record<string, string> {
|
|
@@ -30,7 +30,6 @@ export function summarizeError(error: unknown): Record<string, string> {
|
|
|
30
30
|
|
|
31
31
|
export function isRouteCommandId(commandId: string): commandId is RouteCommandId {
|
|
32
32
|
return commandId === "security-description"
|
|
33
|
-
|| commandId === "theme"
|
|
34
33
|
|| commandId === "plugins"
|
|
35
34
|
|| commandId === "layout";
|
|
36
35
|
}
|
|
@@ -237,12 +236,10 @@ export function normalizeWizardFields(steps: WizardStep[]): {
|
|
|
237
236
|
return { fields, description, initialValues, pendingLabel, successLabel };
|
|
238
237
|
}
|
|
239
238
|
|
|
240
|
-
export function routeCommandIdToScreen(commandId: RouteCommandId): "ticker-search" | "
|
|
239
|
+
export function routeCommandIdToScreen(commandId: RouteCommandId): "ticker-search" | "plugins" | "layout" {
|
|
241
240
|
switch (commandId) {
|
|
242
241
|
case "security-description":
|
|
243
242
|
return "ticker-search";
|
|
244
|
-
case "theme":
|
|
245
|
-
return "themes";
|
|
246
243
|
case "plugins":
|
|
247
244
|
return "plugins";
|
|
248
245
|
case "layout":
|
|
@@ -5,10 +5,9 @@ export interface ResultItem {
|
|
|
5
5
|
label: string;
|
|
6
6
|
detail: string;
|
|
7
7
|
category: string;
|
|
8
|
-
kind: "command" | "ticker" | "search" | "
|
|
8
|
+
kind: "command" | "ticker" | "search" | "plugin" | "action" | "info";
|
|
9
9
|
right?: string;
|
|
10
10
|
searchText?: string;
|
|
11
|
-
themeId?: string;
|
|
12
11
|
pluginToggle?: () => void | Promise<void>;
|
|
13
12
|
secondaryAction?: () => void | Promise<void>;
|
|
14
13
|
checked?: boolean;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
memo,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { Box, ScrollBox, Text } from "../../ui";
|
|
13
|
+
import { TextAttributes, type ScrollBoxRenderable } from "../../ui";
|
|
14
|
+
import { getThemeIds, themes as themeRegistry } from "../../theme/themes";
|
|
15
|
+
import { truncateText } from "./view-model";
|
|
16
|
+
|
|
17
|
+
const THEME_PREVIEW_DEBOUNCE_MS = 120;
|
|
18
|
+
const THEME_OPTIONS = getThemeIds().map((id) => ({
|
|
19
|
+
id,
|
|
20
|
+
name: themeRegistry[id]!.name,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
interface ThemePickerScrollEvent {
|
|
24
|
+
stopPropagation: () => void;
|
|
25
|
+
preventDefault: () => void;
|
|
26
|
+
scroll?: { direction?: string; delta?: number };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ThemePickerProps {
|
|
30
|
+
filter: string;
|
|
31
|
+
committedThemeId: string;
|
|
32
|
+
height: number;
|
|
33
|
+
contentPadding: number;
|
|
34
|
+
labelWidth: number;
|
|
35
|
+
trailingWidth: number;
|
|
36
|
+
queryDisplayWidth: number;
|
|
37
|
+
nativePaneChrome: boolean;
|
|
38
|
+
paletteBg: string;
|
|
39
|
+
paletteHoverBg: string;
|
|
40
|
+
paletteSelectedBg: string;
|
|
41
|
+
paletteSelectedText: string;
|
|
42
|
+
paletteSubtleText: string;
|
|
43
|
+
paletteText: string;
|
|
44
|
+
panelBg: string;
|
|
45
|
+
onPreview: (themeId: string | null) => void;
|
|
46
|
+
onCommit: (themeId: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ThemePickerHandle {
|
|
50
|
+
move: (delta: number) => boolean;
|
|
51
|
+
commit: () => boolean;
|
|
52
|
+
cancelPreview: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clampIndex(index: number, length: number): number {
|
|
56
|
+
return Math.max(0, Math.min(index, Math.max(0, length - 1)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const ThemePicker = memo(forwardRef<ThemePickerHandle, ThemePickerProps>(function ThemePicker({
|
|
60
|
+
filter,
|
|
61
|
+
committedThemeId,
|
|
62
|
+
height,
|
|
63
|
+
contentPadding,
|
|
64
|
+
labelWidth,
|
|
65
|
+
trailingWidth,
|
|
66
|
+
queryDisplayWidth,
|
|
67
|
+
nativePaneChrome,
|
|
68
|
+
paletteBg,
|
|
69
|
+
paletteHoverBg,
|
|
70
|
+
paletteSelectedBg,
|
|
71
|
+
paletteSelectedText,
|
|
72
|
+
paletteSubtleText,
|
|
73
|
+
paletteText,
|
|
74
|
+
panelBg,
|
|
75
|
+
onPreview,
|
|
76
|
+
onCommit,
|
|
77
|
+
}: ThemePickerProps, ref) {
|
|
78
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
79
|
+
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
80
|
+
const pendingPreviewIdRef = useRef<string | null>(null);
|
|
81
|
+
const committedThemeIdRef = useRef(committedThemeId);
|
|
82
|
+
const onPreviewRef = useRef(onPreview);
|
|
83
|
+
const onCommitRef = useRef(onCommit);
|
|
84
|
+
const normalizedFilter = filter.trim().toLowerCase();
|
|
85
|
+
const themes = useMemo(() => {
|
|
86
|
+
if (!normalizedFilter) return THEME_OPTIONS;
|
|
87
|
+
return THEME_OPTIONS.filter((theme) => (
|
|
88
|
+
theme.name.toLowerCase().includes(normalizedFilter)
|
|
89
|
+
|| theme.id.toLowerCase().includes(normalizedFilter)
|
|
90
|
+
));
|
|
91
|
+
}, [normalizedFilter]);
|
|
92
|
+
const [selectedIndex, setSelectedIndex] = useState(() => (
|
|
93
|
+
Math.max(0, themes.findIndex((theme) => theme.id === committedThemeId))
|
|
94
|
+
));
|
|
95
|
+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
|
96
|
+
const themesRef = useRef(themes);
|
|
97
|
+
const selectedIndexRef = useRef(selectedIndex);
|
|
98
|
+
|
|
99
|
+
themesRef.current = themes;
|
|
100
|
+
selectedIndexRef.current = selectedIndex;
|
|
101
|
+
committedThemeIdRef.current = committedThemeId;
|
|
102
|
+
onPreviewRef.current = onPreview;
|
|
103
|
+
onCommitRef.current = onCommit;
|
|
104
|
+
|
|
105
|
+
const cancelPreview = useCallback(() => {
|
|
106
|
+
if (previewTimerRef.current) {
|
|
107
|
+
clearTimeout(previewTimerRef.current);
|
|
108
|
+
previewTimerRef.current = null;
|
|
109
|
+
}
|
|
110
|
+
pendingPreviewIdRef.current = null;
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const requestPreview = useCallback((themeId: string) => {
|
|
114
|
+
pendingPreviewIdRef.current = themeId;
|
|
115
|
+
if (previewTimerRef.current) {
|
|
116
|
+
clearTimeout(previewTimerRef.current);
|
|
117
|
+
}
|
|
118
|
+
previewTimerRef.current = setTimeout(() => {
|
|
119
|
+
previewTimerRef.current = null;
|
|
120
|
+
const nextThemeId = pendingPreviewIdRef.current;
|
|
121
|
+
pendingPreviewIdRef.current = null;
|
|
122
|
+
if (!nextThemeId) return;
|
|
123
|
+
onPreviewRef.current(nextThemeId === committedThemeIdRef.current ? null : nextThemeId);
|
|
124
|
+
}, THEME_PREVIEW_DEBOUNCE_MS);
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const move = useCallback((delta: number): boolean => {
|
|
128
|
+
const options = themesRef.current;
|
|
129
|
+
if (options.length === 0 || delta === 0) return false;
|
|
130
|
+
const nextIndex = clampIndex(selectedIndexRef.current + delta, options.length);
|
|
131
|
+
if (nextIndex === selectedIndexRef.current) return false;
|
|
132
|
+
selectedIndexRef.current = nextIndex;
|
|
133
|
+
setSelectedIndex(nextIndex);
|
|
134
|
+
setHoveredIndex(null);
|
|
135
|
+
requestPreview(options[nextIndex]!.id);
|
|
136
|
+
return true;
|
|
137
|
+
}, [requestPreview]);
|
|
138
|
+
|
|
139
|
+
const commit = useCallback((): boolean => {
|
|
140
|
+
const selected = themesRef.current[selectedIndexRef.current];
|
|
141
|
+
if (!selected) return false;
|
|
142
|
+
cancelPreview();
|
|
143
|
+
onCommitRef.current(selected.id);
|
|
144
|
+
return true;
|
|
145
|
+
}, [cancelPreview]);
|
|
146
|
+
|
|
147
|
+
useImperativeHandle(ref, () => ({
|
|
148
|
+
move,
|
|
149
|
+
commit,
|
|
150
|
+
cancelPreview,
|
|
151
|
+
}), [cancelPreview, commit, move]);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
const preferredIndex = themes.findIndex((theme) => theme.id === committedThemeId);
|
|
155
|
+
const nextIndex = preferredIndex >= 0 ? preferredIndex : 0;
|
|
156
|
+
selectedIndexRef.current = nextIndex;
|
|
157
|
+
setSelectedIndex(nextIndex);
|
|
158
|
+
setHoveredIndex(null);
|
|
159
|
+
}, [committedThemeId, themes]);
|
|
160
|
+
|
|
161
|
+
useEffect(() => cancelPreview, [cancelPreview]);
|
|
162
|
+
|
|
163
|
+
useLayoutEffect(() => {
|
|
164
|
+
const scrollBox = scrollRef.current;
|
|
165
|
+
if (!scrollBox) return;
|
|
166
|
+
if (scrollBox.verticalScrollBar) scrollBox.verticalScrollBar.visible = false;
|
|
167
|
+
if (themes.length === 0) return;
|
|
168
|
+
const viewportHeight = Math.max(1, scrollBox.viewport?.height ?? height);
|
|
169
|
+
if (selectedIndex < scrollBox.scrollTop) {
|
|
170
|
+
scrollBox.scrollTo(selectedIndex);
|
|
171
|
+
} else if (selectedIndex >= scrollBox.scrollTop + viewportHeight) {
|
|
172
|
+
scrollBox.scrollTo(selectedIndex - viewportHeight + 1);
|
|
173
|
+
}
|
|
174
|
+
}, [height, selectedIndex, themes.length]);
|
|
175
|
+
|
|
176
|
+
const handleScroll = useCallback((event: ThemePickerScrollEvent) => {
|
|
177
|
+
event.stopPropagation();
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
const delta = Math.max(1, Math.round(event.scroll?.delta ?? 1));
|
|
180
|
+
const direction = event.scroll?.direction;
|
|
181
|
+
if (direction === "down" || direction === "right") {
|
|
182
|
+
move(delta);
|
|
183
|
+
} else if (direction === "up" || direction === "left") {
|
|
184
|
+
move(-delta);
|
|
185
|
+
}
|
|
186
|
+
}, [move]);
|
|
187
|
+
|
|
188
|
+
if (themes.length === 0) {
|
|
189
|
+
return (
|
|
190
|
+
<Box height={height} paddingX={contentPadding}>
|
|
191
|
+
<Text fg={paletteSubtleText}>{truncateText("No themes match", queryDisplayWidth)}</Text>
|
|
192
|
+
</Box>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<ScrollBox
|
|
198
|
+
ref={scrollRef}
|
|
199
|
+
flexDirection="column"
|
|
200
|
+
height={height}
|
|
201
|
+
scrollY
|
|
202
|
+
focusable={false}
|
|
203
|
+
{...(!nativePaneChrome ? { onMouseScroll: handleScroll } : {})}
|
|
204
|
+
>
|
|
205
|
+
{themes.map((theme, index) => {
|
|
206
|
+
const selected = index === selectedIndex;
|
|
207
|
+
const hovered = index === hoveredIndex && !selected;
|
|
208
|
+
const current = theme.id === committedThemeId;
|
|
209
|
+
const label = truncateText(theme.name, labelWidth);
|
|
210
|
+
const trailing = current ? "current" : "";
|
|
211
|
+
return (
|
|
212
|
+
<Box
|
|
213
|
+
key={theme.id}
|
|
214
|
+
flexDirection="row"
|
|
215
|
+
height={1}
|
|
216
|
+
paddingX={contentPadding}
|
|
217
|
+
backgroundColor={selected
|
|
218
|
+
? paletteSelectedBg
|
|
219
|
+
: hovered
|
|
220
|
+
? paletteHoverBg
|
|
221
|
+
: (nativePaneChrome ? panelBg : paletteBg)}
|
|
222
|
+
onMouseMove={() => setHoveredIndex(index)}
|
|
223
|
+
onMouseOut={() => setHoveredIndex(null)}
|
|
224
|
+
onMouseDown={(event: any) => {
|
|
225
|
+
event.stopPropagation?.();
|
|
226
|
+
event.preventDefault?.();
|
|
227
|
+
selectedIndexRef.current = index;
|
|
228
|
+
setSelectedIndex(index);
|
|
229
|
+
cancelPreview();
|
|
230
|
+
onCommitRef.current(theme.id);
|
|
231
|
+
}}
|
|
232
|
+
{...(!nativePaneChrome ? { onMouseScroll: handleScroll } : {})}
|
|
233
|
+
data-command-bar-row-selected={nativePaneChrome && selected ? "true" : undefined}
|
|
234
|
+
style={nativePaneChrome ? { borderRadius: 6 } : undefined}
|
|
235
|
+
>
|
|
236
|
+
<Box width={labelWidth}>
|
|
237
|
+
<Text
|
|
238
|
+
fg={selected ? paletteSelectedText : paletteText}
|
|
239
|
+
attributes={current ? TextAttributes.BOLD : undefined}
|
|
240
|
+
>
|
|
241
|
+
{label}
|
|
242
|
+
</Text>
|
|
243
|
+
</Box>
|
|
244
|
+
<Box width={trailingWidth}>
|
|
245
|
+
<Text fg={selected ? paletteSelectedText : paletteSubtleText}>
|
|
246
|
+
{truncateText(trailing, trailingWidth)}
|
|
247
|
+
</Text>
|
|
248
|
+
</Box>
|
|
249
|
+
</Box>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</ScrollBox>
|
|
253
|
+
);
|
|
254
|
+
}));
|
|
@@ -46,7 +46,7 @@ describe("command bar view model helpers", () => {
|
|
|
46
46
|
expect(sections.map((section) => section.category)).toEqual(["Tickers", "Config", "Danger", "Debug"]);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test("derives row presentation for toggles and current
|
|
49
|
+
test("derives row presentation for toggles and current rows", () => {
|
|
50
50
|
expect(getRowPresentation({
|
|
51
51
|
id: "plugin:news",
|
|
52
52
|
label: "News",
|
|
@@ -61,11 +61,11 @@ describe("command bar view model helpers", () => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
expect(getRowPresentation({
|
|
64
|
-
id: "
|
|
64
|
+
id: "current:amber",
|
|
65
65
|
label: "Amber",
|
|
66
66
|
detail: "Warm terminal palette",
|
|
67
|
-
category: "
|
|
68
|
-
kind: "
|
|
67
|
+
category: "Config",
|
|
68
|
+
kind: "command",
|
|
69
69
|
right: "amber",
|
|
70
70
|
current: true,
|
|
71
71
|
}, false, true)).toMatchObject({
|
|
@@ -20,7 +20,7 @@ export interface CommandBarItemView {
|
|
|
20
20
|
label: string;
|
|
21
21
|
detail: string;
|
|
22
22
|
category: string;
|
|
23
|
-
kind: "command" | "ticker" | "search" | "
|
|
23
|
+
kind: "command" | "ticker" | "search" | "plugin" | "action" | "info";
|
|
24
24
|
right?: string;
|
|
25
25
|
checked?: boolean;
|
|
26
26
|
current?: boolean;
|
|
@@ -38,11 +38,10 @@ export interface CommandBarRouteBase {
|
|
|
38
38
|
|
|
39
39
|
export interface CommandBarModeRoute extends CommandBarRouteBase {
|
|
40
40
|
kind: "mode";
|
|
41
|
-
screen: "ticker-search" | "
|
|
41
|
+
screen: "ticker-search" | "plugins" | "layout";
|
|
42
42
|
query: string;
|
|
43
43
|
selectedIdx: number;
|
|
44
44
|
hoveredIdx: number | null;
|
|
45
|
-
themeBaseId?: string;
|
|
46
45
|
payload?: Record<string, unknown>;
|
|
47
46
|
}
|
|
48
47
|
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { Box, Text, useRendererHost, useUiCapabilities } from "../../ui";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { useShortcut, useViewport } from "../../react/input";
|
|
4
4
|
import { useAppDispatch, useAppSelector } from "../../state/app-context";
|
|
5
5
|
import type { DesktopWindowBridge } from "../../types/desktop-window";
|
|
6
6
|
import { findPaneInstance } from "../../types/config";
|
|
7
7
|
import type { PluginRegistry } from "../../plugins/registry";
|
|
8
8
|
import { colors, floatingPaneBg, floatingPaneTitleBg, paneTitleText } from "../../theme/colors";
|
|
9
|
+
import { useThemeColors } from "../../theme/theme-context";
|
|
9
10
|
import { hasPaneFooterContent, PaneFooterBar, PaneFooterProvider } from "./pane-footer";
|
|
10
11
|
import { PaneContent } from "./pane-content";
|
|
11
|
-
import { getPaneBodyWidth } from "./pane-sizing";
|
|
12
|
+
import { getPaneBodyWidth, NATIVE_PANE_BODY_LAYOUT_PROPS } from "./pane-sizing";
|
|
12
13
|
import { getPaneDisplayTitle } from "./pane-title";
|
|
13
14
|
import { TITLEBAR_OVERLAY_HEIGHT_PX, TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
|
|
15
|
+
import {
|
|
16
|
+
createDoubleEscapeCloseState,
|
|
17
|
+
recordDoubleEscapeClose,
|
|
18
|
+
resetDoubleEscapeClose,
|
|
19
|
+
} from "../../utils/double-escape-close";
|
|
14
20
|
|
|
15
21
|
interface DetachedPaneShellProps {
|
|
16
22
|
pluginRegistry: PluginRegistry;
|
|
@@ -23,12 +29,13 @@ function stopMouse(event?: { stopPropagation?: () => void; preventDefault?: () =
|
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: DetachedPaneShellProps) {
|
|
32
|
+
useThemeColors();
|
|
26
33
|
const dispatch = useAppDispatch();
|
|
27
34
|
const rendererHost = useRendererHost();
|
|
28
35
|
const config = useAppSelector((state) => state.config);
|
|
29
36
|
const paneState = useAppSelector((state) => state.paneState);
|
|
30
|
-
const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
|
|
31
37
|
const inputCaptured = useAppSelector((state) => state.inputCaptured);
|
|
38
|
+
const doubleEscapeCloseRef = useRef(createDoubleEscapeCloseState());
|
|
32
39
|
const [windowFocused, setWindowFocused] = useState(() => (
|
|
33
40
|
typeof document === "undefined" ? true : document.hasFocus()
|
|
34
41
|
));
|
|
@@ -52,12 +59,6 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
52
59
|
dispatch({ type: "FOCUS_PANE", paneId: desktopWindowBridge.paneId });
|
|
53
60
|
}, [desktopWindowBridge.paneId, dispatch]);
|
|
54
61
|
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
if (windowFocused && focusedPaneId !== desktopWindowBridge.paneId) {
|
|
57
|
-
focusPane();
|
|
58
|
-
}
|
|
59
|
-
}, [desktopWindowBridge.paneId, focusPane, focusedPaneId, windowFocused]);
|
|
60
|
-
|
|
61
62
|
useEffect(() => {
|
|
62
63
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
63
64
|
|
|
@@ -81,6 +82,21 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
81
82
|
};
|
|
82
83
|
}, [focusPane]);
|
|
83
84
|
|
|
85
|
+
useShortcut((event) => {
|
|
86
|
+
const doubleEscapeState = doubleEscapeCloseRef.current;
|
|
87
|
+
const isEscape = event.name === "escape" || event.name === "esc";
|
|
88
|
+
if (isEscape) {
|
|
89
|
+
if (recordDoubleEscapeClose(doubleEscapeState, desktopWindowBridge.paneId, Date.now())) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
event.stopPropagation();
|
|
92
|
+
void desktopWindowBridge.closeDetachedPane?.(desktopWindowBridge.paneId);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
resetDoubleEscapeClose(doubleEscapeState);
|
|
98
|
+
}, { phase: "before" });
|
|
99
|
+
|
|
84
100
|
useShortcut((event) => {
|
|
85
101
|
if (event.name !== "w" || (!event.ctrl && !event.meta && !event.super)) return;
|
|
86
102
|
if (inputCaptured && event.ctrl && !event.meta && !event.super) return;
|
|
@@ -116,6 +132,9 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
116
132
|
const bodyHeight = Math.max(1, height - headerHeightRows - footerHeightRows);
|
|
117
133
|
const background = floatingPaneBg(focused);
|
|
118
134
|
const titleBackground = floatingPaneTitleBg(focused);
|
|
135
|
+
const bodyLayoutProps = nativePaneChrome
|
|
136
|
+
? NATIVE_PANE_BODY_LAYOUT_PROPS
|
|
137
|
+
: { height: bodyHeight };
|
|
119
138
|
|
|
120
139
|
return (
|
|
121
140
|
<Box
|
|
@@ -166,7 +185,7 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
|
|
|
166
185
|
)}
|
|
167
186
|
</Box>
|
|
168
187
|
</Box>
|
|
169
|
-
<Box
|
|
188
|
+
<Box {...bodyLayoutProps} overflow="hidden" backgroundColor={background}>
|
|
170
189
|
<PaneContent
|
|
171
190
|
component={paneDef.component}
|
|
172
191
|
paneId={instance.instanceId}
|
|
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
import { colors, floatingPaneBg } from "../../theme/colors";
|
|
4
4
|
import { PaneHeader } from "./pane-header";
|
|
5
5
|
import { hasPaneFooterContent, PaneFooterBar, type CombinedPaneFooter } from "./pane-footer";
|
|
6
|
-
import { getPaneBodyHeight, shouldReservePaneFooter } from "./pane-sizing";
|
|
6
|
+
import { getPaneBodyHeight, NATIVE_PANE_BODY_LAYOUT_PROPS, shouldReservePaneFooter } from "./pane-sizing";
|
|
7
7
|
|
|
8
8
|
interface FloatingPaneWrapperProps {
|
|
9
9
|
paneId?: string;
|
|
@@ -78,6 +78,9 @@ export function FloatingPaneWrapper({
|
|
|
78
78
|
const showFooter = hasPaneFooterContent(footer);
|
|
79
79
|
const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
|
|
80
80
|
const bodyHeight = getPaneBodyHeight(height, reserveFooter);
|
|
81
|
+
const bodyLayoutProps = nativePaneChrome
|
|
82
|
+
? NATIVE_PANE_BODY_LAYOUT_PROPS
|
|
83
|
+
: { height: bodyHeight };
|
|
81
84
|
|
|
82
85
|
return (
|
|
83
86
|
<Box
|
|
@@ -115,7 +118,7 @@ export function FloatingPaneWrapper({
|
|
|
115
118
|
/>
|
|
116
119
|
|
|
117
120
|
{/* Content */}
|
|
118
|
-
<Box
|
|
121
|
+
<Box {...bodyLayoutProps} overflow="hidden" backgroundColor={bg}>
|
|
119
122
|
{children}
|
|
120
123
|
</Box>
|
|
121
124
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box, SpinnerMark, Text, TextAttributes, useUiCapabilities } from "../../ui";
|
|
2
2
|
import { useEffect, type ReactNode } from "react";
|
|
3
3
|
import { blendHex, colors, priceColor } from "../../theme/colors";
|
|
4
|
+
import { useThemeColors } from "../../theme/theme-context";
|
|
4
5
|
import { useAppActive } from "../../state/app-activity";
|
|
5
6
|
import { useAppDispatch, useAppSelector } from "../../state/app-context";
|
|
6
7
|
import {
|
|
@@ -121,6 +122,7 @@ function UpdateStatus() {
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
export function Header() {
|
|
125
|
+
useThemeColors();
|
|
124
126
|
const baseCurrency = useAppSelector(selectBaseCurrency);
|
|
125
127
|
const appActive = useAppActive();
|
|
126
128
|
const { titleBarOverlay } = useUiCapabilities();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, type Dispatch } from "react";
|
|
3
|
+
import { testRender } from "../../renderers/opentui/test-utils";
|
|
4
|
+
import { AppProvider, useAppDispatch, type AppAction } from "../../state/app-context";
|
|
5
|
+
import { applyTheme, colors } from "../../theme/colors";
|
|
6
|
+
import { getTheme } from "../../theme/themes";
|
|
7
|
+
import { createDefaultConfig } from "../../types/config";
|
|
8
|
+
import { PaneContent } from "./pane-content";
|
|
9
|
+
|
|
10
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
11
|
+
let capturedDispatch: Dispatch<AppAction> | null = null;
|
|
12
|
+
|
|
13
|
+
function DispatchCapture() {
|
|
14
|
+
capturedDispatch = useAppDispatch();
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ThemeColorProbe() {
|
|
19
|
+
return <text>{colors.textBright}</text>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("PaneContent", () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
testSetup?.renderer.destroy();
|
|
25
|
+
testSetup = undefined;
|
|
26
|
+
capturedDispatch = null;
|
|
27
|
+
applyTheme("amber");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("rerenders memoized pane bodies when the theme preview changes", async () => {
|
|
31
|
+
const config = {
|
|
32
|
+
...createDefaultConfig("/tmp/gloomberb-test"),
|
|
33
|
+
theme: "amber",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
testSetup = await testRender(
|
|
37
|
+
<AppProvider config={config}>
|
|
38
|
+
<DispatchCapture />
|
|
39
|
+
<PaneContent
|
|
40
|
+
component={ThemeColorProbe}
|
|
41
|
+
paneId="theme-preview:test"
|
|
42
|
+
paneType="test"
|
|
43
|
+
focused
|
|
44
|
+
width={24}
|
|
45
|
+
height={4}
|
|
46
|
+
/>
|
|
47
|
+
</AppProvider>,
|
|
48
|
+
{ width: 32, height: 6 },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
await testSetup.renderOnce();
|
|
52
|
+
expect(testSetup.captureCharFrame()).toContain(getTheme("amber").textBright);
|
|
53
|
+
|
|
54
|
+
await act(async () => {
|
|
55
|
+
capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
|
|
56
|
+
await testSetup!.renderOnce();
|
|
57
|
+
});
|
|
58
|
+
await testSetup.renderOnce();
|
|
59
|
+
|
|
60
|
+
const frame = testSetup.captureCharFrame();
|
|
61
|
+
expect(frame).toContain(getTheme("green").textBright);
|
|
62
|
+
expect(frame).not.toContain(getTheme("amber").textBright);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useCallback } from "react";
|
|
2
2
|
import { PaneInstanceProvider } from "../../state/app-context";
|
|
3
3
|
import { PaneKeyboardScrollController } from "../../state/pane-scroll-registry";
|
|
4
|
+
import { useThemeColors } from "../../theme/theme-context";
|
|
4
5
|
import type { PaneDef } from "../../types/plugin";
|
|
5
6
|
|
|
6
7
|
interface PaneContentProps {
|
|
@@ -22,6 +23,7 @@ export const PaneContent = memo(function PaneContent({
|
|
|
22
23
|
height,
|
|
23
24
|
onClose,
|
|
24
25
|
}: PaneContentProps) {
|
|
26
|
+
useThemeColors();
|
|
25
27
|
const close = useCallback(() => {
|
|
26
28
|
onClose?.(paneId);
|
|
27
29
|
}, [onClose, paneId]);
|
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
const PANE_HEADER_ROWS = 1;
|
|
3
3
|
const PANE_FOOTER_ROWS = 1;
|
|
4
4
|
|
|
5
|
+
export const NATIVE_PANE_BODY_LAYOUT_PROPS = {
|
|
6
|
+
flexGrow: 1,
|
|
7
|
+
flexBasis: 0,
|
|
8
|
+
minHeight: 0,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
5
11
|
export function shouldReservePaneFooter(nativePaneChrome: boolean | undefined, showFooter: boolean): boolean {
|
|
6
12
|
return !nativePaneChrome || showFooter;
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
export function getPaneBodyHeight(height: number, reserveFooter = true): number {
|
|
10
|
-
|
|
16
|
+
const cellHeight = Math.max(1, Math.floor(height));
|
|
17
|
+
return Math.max(1, cellHeight - PANE_HEADER_ROWS - (reserveFooter ? PANE_FOOTER_ROWS : 0));
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
export function getPaneBodyWidth(width: number): number {
|
|
14
|
-
return Math.max(1, width);
|
|
21
|
+
return Math.max(1, Math.floor(width));
|
|
15
22
|
}
|
|
@@ -8,7 +8,10 @@ export function getPaneDisplayTitle(
|
|
|
8
8
|
paneDef: PaneDef,
|
|
9
9
|
): string {
|
|
10
10
|
if (instance.paneId === "chat") {
|
|
11
|
-
|
|
11
|
+
const channelId = typeof instance.settings?.channelId === "string" && instance.settings.channelId.trim()
|
|
12
|
+
? instance.settings.channelId.trim()
|
|
13
|
+
: "everyone";
|
|
14
|
+
return instance.title ?? `#${channelId}`;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
if (instance.paneId === "ticker-detail") {
|
|
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
import { colors, paneBg } from "../../theme/colors";
|
|
4
4
|
import { PaneHeader } from "./pane-header";
|
|
5
5
|
import { hasPaneFooterContent, PaneFooterBar, type CombinedPaneFooter } from "./pane-footer";
|
|
6
|
-
import { getPaneBodyHeight, shouldReservePaneFooter } from "./pane-sizing";
|
|
6
|
+
import { getPaneBodyHeight, NATIVE_PANE_BODY_LAYOUT_PROPS, shouldReservePaneFooter } from "./pane-sizing";
|
|
7
7
|
|
|
8
8
|
interface PaneWrapperProps {
|
|
9
9
|
paneId?: string;
|
|
@@ -47,8 +47,15 @@ export function PaneWrapper({
|
|
|
47
47
|
const showFooter = hasPaneFooterContent(footer);
|
|
48
48
|
const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
|
|
49
49
|
const bodyHeight = typeof height === "number"
|
|
50
|
-
? title ? getPaneBodyHeight(height, reserveFooter) : height
|
|
50
|
+
? title ? getPaneBodyHeight(height, reserveFooter) : Math.max(1, Math.floor(height))
|
|
51
51
|
: undefined;
|
|
52
|
+
const bodyLayoutProps = nativePaneChrome
|
|
53
|
+
? NATIVE_PANE_BODY_LAYOUT_PROPS
|
|
54
|
+
: {
|
|
55
|
+
height: bodyHeight,
|
|
56
|
+
flexGrow: bodyHeight == null ? 1 : 0,
|
|
57
|
+
flexBasis: bodyHeight == null ? 0 : undefined,
|
|
58
|
+
};
|
|
52
59
|
|
|
53
60
|
return (
|
|
54
61
|
<Box
|
|
@@ -82,10 +89,9 @@ export function PaneWrapper({
|
|
|
82
89
|
/>
|
|
83
90
|
)}
|
|
84
91
|
<Box
|
|
85
|
-
|
|
86
|
-
flexGrow={bodyHeight == null ? 1 : 0}
|
|
87
|
-
flexBasis={bodyHeight == null ? 0 : undefined}
|
|
92
|
+
{...bodyLayoutProps}
|
|
88
93
|
overflow="hidden"
|
|
94
|
+
backgroundColor={bg}
|
|
89
95
|
>
|
|
90
96
|
{children}
|
|
91
97
|
</Box>
|