Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add initial idea for flares #2680

Merged
merged 19 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Changes to Calva.

## [Unreleased]

- [Add flare handler and webview](https://github.com/BetterThanTomorrow/calva/issues/2679)

## [2.0.491] - 2025-03-15

- [Add support for multiple default connect sequences based on different project roots](https://github.com/BetterThanTomorrow/calva/issues/2753)
Expand Down
108 changes: 108 additions & 0 deletions docs/site/flares.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
title: Flares and Webviews
description: Learn how to use Flares to show HTML and messages in Calva.
---

# Flares

Flares are special values that request Calva behavior like showing HTML in a WebView panel.
Flares are used by tools like [Clay](https://scicloj.github.io/clay/) to show data visualizations.
You can make Custom REPL Commands that produce flares.
Calva inspects all REPL evaluation for flares, so you can use them in the REPL too.
Flares can be used with any type of REPL, including: Clojure, ClojureScript, Babashka, Joyride.

## Try Flares in the REPL

Try this example by copying it to your Calva REPL:

```clojure
(tagged-literal 'flare/html {:html "<h1>Hello, Flares!</h1>",
:title "Greeting"
:key "example"})
```

A WebView panel opens up showing the rendered HTML content.

Flares are [tagged literals](https://clojure.org/reference/reader#tagged_literals) consisting of a tag indicating the desired action, and a map containing the request details.
The `tagged-literal` function is a clojure.core function that creates the special value.
Clojure prints tagged literals as `#tag{...}`, so when you create the flare, it will be printed as `#flare/html{...}`.

- **Tag**: `flare/html` – Indicates a WebView request
- **Request**: `{:html "..."}` - HTML content to display

To show a webpage, pass a `:url` instead of `:html` in the request:

```clojure
(tagged-literal 'flare/html {:url "https://calva.io/",
:title "Calva homepage"
:key "example"})
```

The `:key` parameter is optional and can be used to reuse the same WebView panel.
If omitted, a new WebView panel will be created per request.

## Try a more interesting example

Let's create an SVG containing circles of varying radii and colors:

```clojure
(require '[clojure.string :as str])
(defn svg []
(let [circles (for [i (range 10 100 10)]
(let [hue (* (/ i 100) 360) ; Map radius to hue (0-360)
color (str "hsl(" hue ", 100%, 50%)")]
(str "<circle r='" i "' stroke='" color "'/>")))]
(str "<svg height='200' width='200'>"
"<g transform='translate(100,100)' fill='none'>"
(str/join circles)
"</g>"
"</svg>")))
(tagged-literal 'flare/html {:html (svg)
:title "SVG Circles"
:key "example"})
```

Copy this code into your Calva REPL and evaluate it to see the SVG image.
The WebView panel will display the SVG content.

![SVG Circles](images/flare.png)

You can iterate quickly, modifying code and using the flare to see the result.
To make it more convenient, you might use a custom action instead.
Flares are useful for creating your own visualization shortcuts,
and tools can also use Flares to show information in the IDE.

Use [Hiccup](https://github.com/weavejester/hiccup) to generate HTML or SVG.
This example uses string concatenation to avoid setting up the dependency.

## Data Visualization

The WebView panel is perfect for charts, tables and other data visualizations.

[Clay](https://scicloj.github.io/clay/) shows visualizations in a WebView panel by using Flares.
[Clay visualization examples](https://scicloj.github.io/clay/clay_book.examples.html)
[Clay in Calva setup](https://scicloj.github.io/clay/#vscode-calva)

## What can the WebView panel do?

The [WebView panel](https://code.visualstudio.com/api/extension-guides/webview) is a full browser,
so you can do anything you can do in a browser, there's really no limit.

## Flare Reference

### `flare/html`

| Key | Type | Default | Description |
|--- |--- |--- |--- |
| `:title` | string | "WebView" | Shown in the panel title. |
| `:html` | string | nil | HTML to show in a WebView. |
| `:url` | string | nil | Show the page hosted at URL in a WebView. |
| `:key` | string | nil | An identifier for the panel. The request will reuse an open WebView if it exists already. |
| `:reload` | boolean | false | If true, sets the content even if it didn't change. |
| `:reveal` | boolean | true | If true, reveals the panel if it is not visible. |
| `:column` | integer | vscode.ViewColumn.Beside | See [ViewColumn](https://code.visualstudio.com/api/references/vscode-api#ViewColumn) |
| `:opts` | map | {:enableScripts true} | See [WebviewOptions](https://code.visualstudio.com/api/references/vscode-api#WebviewOptions) |

### Suggestions welcome

If there are other use cases for Flares, please let us know.
Binary file added docs/site/images/flare.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ nav:
- fiddle-files.md
- connect-sequences.md
- custom-commands.md
- flares.md
- refactoring.md
- notebooks.md
- clojuredocs.md
Expand Down
3 changes: 3 additions & 0 deletions src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as output from './results-output/output';
import * as inspector from './providers/inspector';
import { resultAsComment } from './util/string-result';
import { highlight } from './highlight/src/extension';
import * as flareHandler from './flare-handler';

let inspectorDataProvider: inspector.InspectorDataProvider;

Expand Down Expand Up @@ -139,6 +140,8 @@ async function evaluateCodeUpdatingUI(

result = value;

flareHandler.inspect(value, (code) => evaluateCodeUpdatingUI(code, options, selection));

if (showResult) {
inspectorDataProvider.addItem(value, false, `[${session.replType}] ${ns}`);
output.appendClojureEval(value, { ns, replSessionType: session.replType }, async () => {
Expand Down
129 changes: 129 additions & 0 deletions src/flare-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as vscode from 'vscode';
import { parseEdn } from '../out/cljs-lib/cljs-lib';

type EvaluateFunction = (code: string) => Promise<string | null>;

type WebviewRequest = {
title?: string;
html?: string;
url?: string;
key?: string;
column?: vscode.ViewColumn;
opts?: any;
then?: string;
};

type ActRequest = WebviewRequest;

const actHandlers: Record<string, (request: ActRequest, EvaluateFunction) => void> = {
html: ({ then, ...request }: WebviewRequest, evaluate: EvaluateFunction) => {
showWebView(request);
},
};

export function inspect(edn: string, evaluate: EvaluateFunction): any {
if (
edn &&
typeof edn === 'string' &&
(edn.startsWith('#flare/') || edn.startsWith('#cursive/'))
) {
try {
// decompose the flare into the tag and the literal
const match = edn.match(/^#(?:flare|cursive)\/(\w+)\s*(\{.*}$)/);
if (match) {
const tag = match[1];
const flare = parseEdn(match[2]);
const handler = actHandlers[tag];
if (handler) {
handler(flare, evaluate);
} else {
void vscode.window.showErrorMessage(`Unknown flare tag: ${JSON.stringify(tag)}`);
}
}
} catch (e) {
console.log('ERROR: inspect failed', e);
}
}
}

// Webview below here (doesn't build when in another file)

const defaultWebviewOptions = {
enableScripts: true,
};

interface CalvaWebPanel extends vscode.WebviewPanel {
url?: string;
}

// keep track of open webviews that have a key
// so that they can be updated in the future
const calvaWebPanels: Record<string, CalvaWebPanel> = {};

function showWebView({
title = 'WebView',
key,
html,
url,
reload = false,
reveal = true,
column = vscode.ViewColumn.Beside,
opts = defaultWebviewOptions,
}: {
title?: string;
key?: string;
html?: string;
url?: string;
reload?: boolean;
reveal?: boolean;
column?: vscode.ViewColumn;
opts?: typeof defaultWebviewOptions;
}): void {
let panel: CalvaWebPanel;
if (key) {
panel = calvaWebPanels[key];
}
if (!panel) {
panel = vscode.window.createWebviewPanel('calva-webview', title, column, opts);
if (key) {
calvaWebPanels[key] = panel;
panel.onDidDispose(() => delete calvaWebPanels[key]);
}
}

if (html && panel.webview.html != html) {
panel.webview.html = html;
}

if (url && (url != panel.url || reload)) {
panel.url = url;
panel.webview.html = urlInIframe(url);
}

if (panel.title !== title) {
panel.title = title;
}

if (reveal) {
panel.reveal();
}
}

function urlInIframe(uri: string): string {
return `<!DOCTYPE html>
<html>
<head>
<style type="text/css">
body, html {
margin: 0; padding: 0; height: 100%; overflow: hidden;
}
#content {
position: absolute; left: 0; right: 0; bottom: 0; top: 0px;
}
</style>
</head>
<body>
<iframe src="${uri}" style="width:100%; height:100%; border:none;"></iframe>
</body>
</html>`;
}