@marimo-team/islands 0.23.15-dev27 → 0.23.15-dev30
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/dist/{ConnectedDataExplorerComponent-CU4fZJzG.js → ConnectedDataExplorerComponent-7p7rt9iw.js} +4 -4
- package/dist/{ErrorBoundary-DE6tzZf-.js → ErrorBoundary-B_CAG7_e.js} +1 -1
- package/dist/{chat-ui-FiwuOgQU.js → chat-ui-CbyDW7Rb.js} +14 -14
- package/dist/{code-visibility-C-WeULC4.js → code-visibility-D1Y9p_ja.js} +9 -10
- package/dist/{constants-T20xxyNf.js → debounce-BOD3DbfP.js} +1 -24
- package/dist/{formats-Dzx4J_z1.js → formats-BX3uDQbB.js} +1 -1
- package/dist/{glide-data-editor-CjTu7ukN.js → glide-data-editor-CwZz71BD.js} +3 -3
- package/dist/{html-to-image-JXL8cvmt.js → html-to-image-CtsZpKdg.js} +2137 -2115
- package/dist/{input-DtsN7xm-.js → input-BGPrFH3g.js} +2 -2
- package/dist/main.js +19 -19
- package/dist/{mermaid-BYqXy_NE.js → mermaid-UdmxG2PZ.js} +2 -2
- package/dist/{process-output-CnDIXtM-.js → process-output-DApSJpc6.js} +1 -1
- package/dist/{reveal-component-DX70xIXW.js → reveal-component-Clfz4vIR.js} +5 -5
- package/dist/{spec-Cz-Bj1JI.js → spec-DSs9v0xx.js} +1 -1
- package/dist/{toDate-CWNNlFEX.js → toDate-CKpRx4TS.js} +3 -4
- package/dist/{useAsyncData-KfHB8wQR.js → useAsyncData-BSOyAbac.js} +1 -1
- package/dist/{useDeepCompareMemoize-nJMtxhm4.js → useDeepCompareMemoize-BTLeWzuR.js} +1 -1
- package/dist/{useLifecycle-DegSo0lV.js → useLifecycle-C6wHjkhW.js} +1 -1
- package/dist/{useTheme-6eZ3GOTS.js → useTheme-Df_vGflw.js} +62 -27
- package/dist/{vega-component-DzyyM9fc.js → vega-component-DNHEV0u0.js} +6 -6
- package/package.json +1 -1
- package/src/components/editor/ai/add-cell-with-ai.tsx +14 -5
- package/src/components/editor/ai/ai-completion-editor.tsx +4 -1
- package/src/components/editor/navigation/__tests__/navigation.test.ts +33 -0
- package/src/components/editor/navigation/navigation.ts +8 -1
- package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +97 -3
- package/src/core/codemirror/completion/signature-hint.ts +78 -11
- package/src/core/constants.ts +6 -0
- package/src/theme/__tests__/useTheme.test.ts +68 -0
- package/src/theme/useTheme.ts +16 -1
|
@@ -5,6 +5,7 @@ import { EditorView, type Tooltip } from "@codemirror/view";
|
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
6
6
|
import {
|
|
7
7
|
asSignatureHint,
|
|
8
|
+
closeSignatureHint,
|
|
8
9
|
setSignatureHintEffect,
|
|
9
10
|
signatureHintField,
|
|
10
11
|
} from "../signature-hint";
|
|
@@ -51,12 +52,79 @@ describe("signatureHintField", () => {
|
|
|
51
52
|
expect(state.field(signatureHintField)).toBeNull();
|
|
52
53
|
});
|
|
53
54
|
|
|
54
|
-
it("keeps and re-anchors the tooltip across edits", () => {
|
|
55
|
+
it("keeps and re-anchors the tooltip across edits inside the call", () => {
|
|
55
56
|
let state = stateWithHint("plt.plot(", 9);
|
|
56
|
-
// Insert before the tooltip position
|
|
57
|
-
|
|
57
|
+
// Insert before the tooltip position while the cursor stays inside the
|
|
58
|
+
// call; the anchor should shift but the hint should remain.
|
|
59
|
+
state = state.update({
|
|
60
|
+
changes: { from: 0, insert: "xy" },
|
|
61
|
+
selection: { anchor: 11 },
|
|
62
|
+
}).state;
|
|
58
63
|
expect(state.field(signatureHintField)?.pos).toBe(11);
|
|
59
64
|
});
|
|
65
|
+
|
|
66
|
+
it("dismisses the tooltip when the closing paren is typed", () => {
|
|
67
|
+
let state = stateWithHint("plt.plot(", 9);
|
|
68
|
+
// Type the closing paren; the cursor is now outside the call.
|
|
69
|
+
state = state.update({
|
|
70
|
+
changes: { from: 9, insert: ")" },
|
|
71
|
+
selection: { anchor: 10 },
|
|
72
|
+
}).state;
|
|
73
|
+
expect(state.field(signatureHintField)).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("dismisses the tooltip when the anchored call closes inside grouping parens", () => {
|
|
77
|
+
// Regression for the `(plt.plot())` case: the outer grouping paren must not
|
|
78
|
+
// keep the (now-closed) plt.plot hint alive.
|
|
79
|
+
let state = stateWithHint("(plt.plot(", 10);
|
|
80
|
+
// Close plt.plot's call; the outer `(` is still open but we've left the
|
|
81
|
+
// anchored call.
|
|
82
|
+
state = state.update({
|
|
83
|
+
changes: { from: 10, insert: ")" },
|
|
84
|
+
selection: { anchor: 11 },
|
|
85
|
+
}).state;
|
|
86
|
+
expect(state.field(signatureHintField)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps the tooltip while typing a nested call inside the anchored call", () => {
|
|
90
|
+
// Cursor inside the anchored call of `f(g(<cursor>`; opening/typing a nested
|
|
91
|
+
// call stays inside the anchored call, so the hint should remain.
|
|
92
|
+
let state = stateWithHint("f(g(", 4);
|
|
93
|
+
state = state.update({
|
|
94
|
+
changes: { from: 4, insert: "x(" },
|
|
95
|
+
selection: { anchor: 6 },
|
|
96
|
+
}).state;
|
|
97
|
+
expect(state.field(signatureHintField)?.pos).toBe(4);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("keeps the tooltip when a nested call closes inside the anchored call", () => {
|
|
101
|
+
const anchor = "f(".length;
|
|
102
|
+
let state = stateWithHint("f(g(x", anchor);
|
|
103
|
+
state = state.update({
|
|
104
|
+
changes: { from: 5, insert: ")" },
|
|
105
|
+
selection: { anchor: 6 },
|
|
106
|
+
}).state;
|
|
107
|
+
expect(state.field(signatureHintField)?.pos).toBe(anchor);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("dismisses the tooltip when the closing paren is typed in a large multi-line call", () => {
|
|
111
|
+
const anchor = "f(".length;
|
|
112
|
+
const prefix = `f(\n${" x,\n".repeat(25)}`;
|
|
113
|
+
let state = EditorState.create({
|
|
114
|
+
doc: prefix,
|
|
115
|
+
selection: { anchor: prefix.length },
|
|
116
|
+
extensions: [signatureHintField],
|
|
117
|
+
});
|
|
118
|
+
state = state.update({
|
|
119
|
+
effects: setSignatureHintEffect.of(fakeTooltip(anchor)),
|
|
120
|
+
}).state;
|
|
121
|
+
const head = prefix.length;
|
|
122
|
+
state = state.update({
|
|
123
|
+
changes: { from: head, insert: ")" },
|
|
124
|
+
selection: { anchor: head + 1 },
|
|
125
|
+
}).state;
|
|
126
|
+
expect(state.field(signatureHintField)).toBeNull();
|
|
127
|
+
});
|
|
60
128
|
});
|
|
61
129
|
|
|
62
130
|
describe("asSignatureHint", () => {
|
|
@@ -92,3 +160,29 @@ describe("asSignatureHint", () => {
|
|
|
92
160
|
expect(wrapped.above).toBe(true);
|
|
93
161
|
});
|
|
94
162
|
});
|
|
163
|
+
|
|
164
|
+
describe("closeSignatureHint", () => {
|
|
165
|
+
it("returns false when no hint is showing", () => {
|
|
166
|
+
const view = new EditorView({
|
|
167
|
+
state: EditorState.create({ extensions: [signatureHintField] }),
|
|
168
|
+
});
|
|
169
|
+
expect(closeSignatureHint(view)).toBe(false);
|
|
170
|
+
expect(view.state.field(signatureHintField)).toBeNull();
|
|
171
|
+
view.destroy();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("dismisses the hint and returns true when one is showing", () => {
|
|
175
|
+
const view = new EditorView({
|
|
176
|
+
state: EditorState.create({
|
|
177
|
+
doc: "plt.plot(",
|
|
178
|
+
extensions: [signatureHintField],
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
view.dispatch({ effects: setSignatureHintEffect.of(fakeTooltip(9)) });
|
|
182
|
+
expect(view.state.field(signatureHintField)?.pos).toBe(9);
|
|
183
|
+
|
|
184
|
+
expect(closeSignatureHint(view)).toBe(true);
|
|
185
|
+
expect(view.state.field(signatureHintField)).toBeNull();
|
|
186
|
+
view.destroy();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -1,12 +1,61 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type { EditorState } from "@codemirror/state";
|
|
2
3
|
import { StateEffect, StateField } from "@codemirror/state";
|
|
3
|
-
import { showTooltip, type Tooltip } from "@codemirror/view";
|
|
4
|
+
import { type EditorView, showTooltip, type Tooltip } from "@codemirror/view";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Effect to set (or clear, with `null`) the floating signature hint.
|
|
7
8
|
*/
|
|
8
9
|
export const setSignatureHintEffect = StateEffect.define<Tooltip | null>();
|
|
9
10
|
|
|
11
|
+
// Bound the scan so large cells stay cheap on every keystroke.
|
|
12
|
+
const MAX_LINES_BACK = 20;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the cursor is still inside the anchored call (just inside its `(`).
|
|
16
|
+
*
|
|
17
|
+
* Anchor-relative paren scan, bounded to {@link MAX_LINES_BACK} lines.
|
|
18
|
+
* Good enough for hint dismissal — not a full parse (ignores strings/comments).
|
|
19
|
+
*/
|
|
20
|
+
function isCursorInsideAnchoredCall(options: {
|
|
21
|
+
state: EditorState;
|
|
22
|
+
anchor: number;
|
|
23
|
+
head: number;
|
|
24
|
+
}): boolean {
|
|
25
|
+
const { state, anchor, head } = options;
|
|
26
|
+
if (head < anchor) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const headLine = state.doc.lineAt(head).number;
|
|
31
|
+
const anchorLine = state.doc.lineAt(anchor).number;
|
|
32
|
+
const startLine = Math.max(anchorLine, headLine - MAX_LINES_BACK + 1);
|
|
33
|
+
const from = Math.max(anchor, state.doc.line(startLine).from);
|
|
34
|
+
|
|
35
|
+
// If the anchor is outside the bounded window, assume its `(` is still open.
|
|
36
|
+
const assumedOpen = from > anchor;
|
|
37
|
+
let balance = assumedOpen ? 1 : 0;
|
|
38
|
+
const iter = state.doc.iterRange(from, head);
|
|
39
|
+
for (;;) {
|
|
40
|
+
const { value, done } = iter.next();
|
|
41
|
+
if (done) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
for (const char of value) {
|
|
45
|
+
if (char === "(") {
|
|
46
|
+
balance++;
|
|
47
|
+
} else if (char === ")") {
|
|
48
|
+
balance--;
|
|
49
|
+
const closed = assumedOpen ? balance <= 0 : balance < 0;
|
|
50
|
+
if (closed) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
10
59
|
/**
|
|
11
60
|
* Wrap a tooltip so it renders like the completion popup's info box.
|
|
12
61
|
*
|
|
@@ -34,13 +83,7 @@ export function asSignatureHint(tooltip: Tooltip): Tooltip {
|
|
|
34
83
|
* Holds the floating "signature hint" shown after typing `(` or `,` inside a
|
|
35
84
|
* call on the non-LSP (Jedi) completion path.
|
|
36
85
|
*
|
|
37
|
-
* The LSP path has its own signature help; this fills the gap for users
|
|
38
|
-
* without a language server. The completion source (`pythonCompletionSource`)
|
|
39
|
-
* drives it: it dispatches `setSignatureHintEffect` with the tooltip when the
|
|
40
|
-
* backend returns a signature and with `null` otherwise. The hint is also
|
|
41
|
-
* cleared when the cursor moves via a selection-only change (e.g. clicking
|
|
42
|
-
* away or arrowing out of the call), and kept anchored across edits so it
|
|
43
|
-
* doesn't flicker while a fresh result is in flight.
|
|
86
|
+
* The LSP path has its own signature help; this fills the gap for users without a language server.
|
|
44
87
|
*/
|
|
45
88
|
export const signatureHintField = StateField.define<Tooltip | null>({
|
|
46
89
|
create: () => null,
|
|
@@ -57,12 +100,36 @@ export const signatureHintField = StateField.define<Tooltip | null>({
|
|
|
57
100
|
if (tr.selection && !tr.docChanged) {
|
|
58
101
|
return null;
|
|
59
102
|
}
|
|
60
|
-
//
|
|
61
|
-
//
|
|
103
|
+
// Dismiss once the cursor leaves the anchored call (e.g. the closing paren
|
|
104
|
+
// is typed). Otherwise keep the hint anchored across edits so it doesn't
|
|
105
|
+
// flicker while a fresh result is in flight; the completion source refreshes
|
|
106
|
+
// or clears it as results arrive.
|
|
62
107
|
if (tr.docChanged) {
|
|
63
|
-
|
|
108
|
+
const anchor = tr.changes.mapPos(tooltip.pos);
|
|
109
|
+
if (
|
|
110
|
+
!isCursorInsideAnchoredCall({
|
|
111
|
+
state: tr.state,
|
|
112
|
+
anchor,
|
|
113
|
+
head: tr.state.selection.main.head,
|
|
114
|
+
})
|
|
115
|
+
) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return { ...tooltip, pos: anchor };
|
|
64
119
|
}
|
|
65
120
|
return tooltip;
|
|
66
121
|
},
|
|
67
122
|
provide: (field) => showTooltip.from(field),
|
|
68
123
|
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Dismiss the floating signature hint if one is showing.
|
|
127
|
+
* Returns `true` if a hint was dismissed.
|
|
128
|
+
*/
|
|
129
|
+
export function closeSignatureHint(view: EditorView): boolean {
|
|
130
|
+
if (view.state.field(signatureHintField, false)) {
|
|
131
|
+
view.dispatch({ effects: setSignatureHintEffect.of(null) });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
package/src/core/constants.ts
CHANGED
|
@@ -50,6 +50,12 @@ export const KnownQueryParams = {
|
|
|
50
50
|
* If false, the chrome will be hidden.
|
|
51
51
|
*/
|
|
52
52
|
showChrome: "show-chrome",
|
|
53
|
+
/**
|
|
54
|
+
* Override the display theme: `light`, `dark`, or `system`.
|
|
55
|
+
* Takes precedence over the notebook's saved `display.theme`.
|
|
56
|
+
* Ignored for embedded islands, which infer the theme from their host page.
|
|
57
|
+
*/
|
|
58
|
+
theme: "theme",
|
|
53
59
|
};
|
|
54
60
|
|
|
55
61
|
/**
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { configOverridesAtom, userConfigAtom } from "@/core/config/config";
|
|
5
|
+
import { defaultUserConfig } from "@/core/config/config-schema";
|
|
6
|
+
import { store } from "@/core/state/jotai";
|
|
7
|
+
import type { Theme } from "../useTheme";
|
|
8
|
+
import { resolvedThemeAtom, visibleForTesting } from "../useTheme";
|
|
9
|
+
|
|
10
|
+
const { themeFromQueryParam } = visibleForTesting;
|
|
11
|
+
|
|
12
|
+
function setQuery(search: string): void {
|
|
13
|
+
window.history.replaceState({}, "", search === "" ? "/" : `/?${search}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setConfigTheme(theme: Theme): void {
|
|
17
|
+
const config = defaultUserConfig();
|
|
18
|
+
store.set(userConfigAtom, {
|
|
19
|
+
...config,
|
|
20
|
+
display: { ...config.display, theme },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
setQuery("");
|
|
26
|
+
store.set(userConfigAtom, defaultUserConfig());
|
|
27
|
+
store.set(configOverridesAtom, {});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("themeFromQueryParam", () => {
|
|
31
|
+
it.each(["light", "dark", "system"] as const)(
|
|
32
|
+
"returns the valid theme %s",
|
|
33
|
+
(theme) => {
|
|
34
|
+
setQuery(`theme=${theme}`);
|
|
35
|
+
expect(themeFromQueryParam()).toBe(theme);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
it("returns undefined when the param is absent", () => {
|
|
40
|
+
setQuery("");
|
|
41
|
+
expect(themeFromQueryParam()).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns undefined for an invalid value", () => {
|
|
45
|
+
setQuery("theme=blue");
|
|
46
|
+
expect(themeFromQueryParam()).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("resolvedThemeAtom with a theme query param", () => {
|
|
51
|
+
it("uses the saved config theme when no param is present", () => {
|
|
52
|
+
setConfigTheme("dark");
|
|
53
|
+
setQuery("");
|
|
54
|
+
expect(store.get(resolvedThemeAtom)).toBe("dark");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("lets the query param override the saved config theme", () => {
|
|
58
|
+
setConfigTheme("light");
|
|
59
|
+
setQuery("theme=dark");
|
|
60
|
+
expect(store.get(resolvedThemeAtom)).toBe("dark");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the config theme for an invalid param value", () => {
|
|
64
|
+
setConfigTheme("dark");
|
|
65
|
+
setQuery("theme=blue");
|
|
66
|
+
expect(store.get(resolvedThemeAtom)).toBe("dark");
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/theme/useTheme.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { atom, useAtomValue } from "jotai";
|
|
4
4
|
import { resolvedMarimoConfigAtom } from "@/core/config/config";
|
|
5
|
+
import { KnownQueryParams } from "@/core/constants";
|
|
5
6
|
import { isIslands } from "@/core/islands/utils";
|
|
6
7
|
import { store } from "@/core/state/jotai";
|
|
7
8
|
|
|
@@ -10,6 +11,16 @@ export type ResolvedTheme = "light" | "dark";
|
|
|
10
11
|
|
|
11
12
|
export const THEMES: Theme[] = ["light", "dark", "system"];
|
|
12
13
|
|
|
14
|
+
function themeFromQueryParam(): Theme | undefined {
|
|
15
|
+
if (typeof window === "undefined") {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const value = new URLSearchParams(window.location.search).get(
|
|
19
|
+
KnownQueryParams.theme,
|
|
20
|
+
);
|
|
21
|
+
return THEMES.includes(value as Theme) ? (value as Theme) : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
const themeAtom = atom((get) => {
|
|
14
25
|
// If it is islands, try a few ways to infer if it is dark mode.
|
|
15
26
|
if (isIslands()) {
|
|
@@ -51,7 +62,7 @@ const themeAtom = atom((get) => {
|
|
|
51
62
|
return "light";
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
return get(resolvedMarimoConfigAtom).display.theme;
|
|
65
|
+
return themeFromQueryParam() ?? get(resolvedMarimoConfigAtom).display.theme;
|
|
55
66
|
});
|
|
56
67
|
|
|
57
68
|
const prefersDarkModeAtom = atom(false);
|
|
@@ -122,3 +133,7 @@ export function useTheme(): { theme: ResolvedTheme } {
|
|
|
122
133
|
const theme = useAtomValue(resolvedThemeAtom, { store });
|
|
123
134
|
return { theme };
|
|
124
135
|
}
|
|
136
|
+
|
|
137
|
+
export const visibleForTesting = {
|
|
138
|
+
themeFromQueryParam,
|
|
139
|
+
};
|