diff --git a/index.html b/index.html
index 781dabc..181d434 100644
--- a/index.html
+++ b/index.html
@@ -64,6 +64,7 @@
+
@@ -85,6 +86,7 @@
diff --git a/js/script.js b/js/script.js
index 64906cf..272e591 100644
--- a/js/script.js
+++ b/js/script.js
@@ -11,6 +11,7 @@ import { xml } from "@codemirror/lang-xml";
import { markdown } from "@codemirror/lang-markdown";
import { syntaxHighlighting, indentUnit } from "@codemirror/language";
import { classHighlighter } from "@lezer/highlight";
+import initRuffFormatter, { PositionEncoding, Workspace } from "@astral-sh/ruff-wasm-web";
import { circuitpythonHighlight } from "./common/circuitpython_highlight.js";
import { getFileIcon } from "./common/file_dialog.js";
@@ -40,6 +41,9 @@ let workflow = null;
let unchanged = 0;
let connectionPromise = null;
let debugMessageAnsi = null;
+let formatterInitPromise = null;
+let formatterWorkspace = null;
+let currentEditorPath = null;
const btnRestart = document.querySelector('.btn-restart');
const btnHalt = document.querySelector('.btn-halt');
@@ -48,6 +52,7 @@ const btnClear = document.querySelector('.btn-clear');
const btnConnect = document.querySelectorAll('.btn-connect');
const btnNew = document.querySelectorAll('.btn-new');
const btnOpen = document.querySelectorAll('.btn-open');
+const btnFormat = document.querySelectorAll('.btn-format');
const btnSave = document.querySelectorAll('.btn-save');
const btnSaveAs = document.querySelectorAll('.btn-save-as');
const btnSaveRun = document.querySelectorAll('.btn-save-run');
@@ -112,6 +117,21 @@ function getFileExtensionFromPath(path) {
return base.split(".").pop().toLowerCase();
}
+function isPythonFilePath(path) {
+ if (path === null || path === undefined) {
+ return true;
+ }
+ return getFileExtensionFromPath(path) === "py";
+}
+
+function updateFormatterButtonState(path) {
+ const enabled = isPythonFilePath(path);
+ btnFormat.forEach((element) => {
+ element.disabled = !enabled;
+ element.setAttribute("aria-disabled", enabled ? "false" : "true");
+ });
+}
+
// Pick the CodeMirror language extensions to use for a given file path.
// Returns an array so callers can spread it directly into the editor's
// extension list. New (untitled) docs default to Python so the editor
@@ -211,6 +231,15 @@ btnOpen.forEach((element) => {
});
});
+// Format Link/Button (Mobile and Desktop Layout)
+btnFormat.forEach((element) => {
+ element.addEventListener('click', async function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ await formatCurrentFile();
+ });
+});
+
// Save Link/Button (Mobile and Desktop Layout)
btnSave.forEach((element) => {
element.addEventListener('click', async function(e) {
@@ -301,6 +330,53 @@ async function openFile() {
}
}
+async function initFormatter() {
+ if (!formatterInitPromise) {
+ formatterInitPromise = initRuffFormatter().then(() => {
+ formatterWorkspace = new Workspace(
+ {
+ "line-length": 88,
+ "indent-width": 4,
+ format: {
+ "indent-style": "space",
+ "quote-style": "double",
+ },
+ },
+ PositionEncoding.Utf16,
+ );
+ });
+ }
+ return formatterInitPromise;
+}
+
+async function formatCurrentFile() {
+ if (!isPythonFilePath(currentEditorPath)) {
+ await showMessage("Format is only available for Python files.");
+ return false;
+ }
+
+ try {
+ await initFormatter();
+ const contents = editor.state.doc.sliceString(0);
+ const formatted = formatterWorkspace.format(contents);
+
+ if (formatted !== contents) {
+ editor.dispatch({
+ changes: {
+ from: 0,
+ to: editor.state.doc.length,
+ insert: formatted,
+ },
+ });
+ }
+ return true;
+ } catch (e) {
+ console.error("Could not format Python file", e);
+ await showMessage(`Could not format Python file. ${e.message || e}`);
+ return false;
+ }
+}
+
async function saveFile() {
if (await checkConnected()) {
await workflow.saveFile();
@@ -436,6 +512,9 @@ async function checkReadOnly() {
/* Update the filename and update the UI */
function setFilename(path) {
+ currentEditorPath = path;
+ updateFormatterButtonState(path);
+
// Refresh the CodeMirror language plugin whenever the active file
// changes — this is the single chokepoint that all filename
// changes route through (Open File, New File, Save As, backend
diff --git a/package-lock.json b/package-lock.json
index 0c4b853..fdd68eb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.0.5",
"@adafruit/circuitpython-repl-js": "adafruit/circuitpython-repl-js#3.3.0",
+ "@astral-sh/ruff-wasm-web": "^0.15.21",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
@@ -49,6 +50,12 @@
"integrity": "sha512-/pWFRIAwwwYXbHfNPw0v6YjX09EjC2yEXAZ98RnVhiQIGh6+N6W9hNKRyCPOBYJvIVXftlyLDHzHc2paV+Oefg==",
"license": "MIT"
},
+ "node_modules/@astral-sh/ruff-wasm-web": {
+ "version": "0.15.21",
+ "resolved": "https://registry.npmjs.org/@astral-sh/ruff-wasm-web/-/ruff-wasm-web-0.15.21.tgz",
+ "integrity": "sha512-EcMPUkDZGAJ0vUAe0hgBWYojGtQAJcz92Nyrsj2P60KB8Bc5eNDuGEGtFbhrRXO/I9pFGG5NNPAJL/uVmyMNig==",
+ "license": "MIT"
+ },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
diff --git a/package.json b/package.json
index dbc76b0..cb6a9be 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"dependencies": {
"@adafruit/ble-file-transfer-js": "adafruit/ble-file-transfer-js#1.0.5",
"@adafruit/circuitpython-repl-js": "adafruit/circuitpython-repl-js#3.3.0",
+ "@astral-sh/ruff-wasm-web": "^0.15.21",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",