diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index 54a6765..5883136 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -10,10 +10,16 @@ name: Build images # # The workflow can also be run by hand from the Actions tab, which uploads the # images as build artifacts without touching releases. +# +# Pull requests run the `extension` job and nothing else. It takes a couple of +# minutes and is the only part of a build that can fail on a code mistake rather +# than on a mirror or a runner; the image jobs are two-hour builds that produce +# gigabyte artifacts, and gating every PR on those would buy very little. on: push: tags: - 'v*' + pull_request: workflow_dispatch: inputs: targets: @@ -31,11 +37,16 @@ permissions: concurrency: group: build-images-${{ github.ref }} - cancel-in-progress: false + # A superseded PR check is wasted minutes, but a release build must never be + # cancelled out from under a tag. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: version: name: Resolve version + # Nothing a pull request runs needs a version, and a check that only ever + # echoes one is noise on the PR. + if: github.event_name != 'pull_request' runs-on: ubuntu-latest outputs: version: ${{ steps.v.outputs.version }} @@ -50,15 +61,59 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" echo "Building VS Code OS ${version}" + # The desktop shell is architecture-neutral JavaScript, so it is built once and + # both image jobs consume the same artifact. Building it inside each image job + # would mean two Node toolchains - pacman's in an Arch container, and + # setup-node's on an arm64 Ubuntu runner - producing the same bytes twice. + extension: + name: Build VsCodeOsCore + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Install + run: npm ci --prefix extension --no-audit --no-fund + + - name: Typecheck + run: npm run --prefix extension typecheck + + - name: Test + run: npm run --prefix extension test + + - name: Build + run: ./scripts/build-extension.sh -o out/extension + + - uses: actions/upload-artifact@v7 + with: + name: vscodeos-core-extension + path: out/extension + retention-days: 14 + x86_64: name: x86-64 ISO - needs: version - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["both","x86_64"]'), github.event.inputs.targets) + needs: [version, extension] + if: >- + github.event_name != 'pull_request' + && (github.event_name != 'workflow_dispatch' + || contains(fromJSON('["both","x86_64"]'), github.event.inputs.targets)) runs-on: ubuntu-latest timeout-minutes: 120 steps: - uses: actions/checkout@v7 + - name: Fetch the prebuilt extension + uses: actions/download-artifact@v8 + with: + name: vscodeos-core-extension + path: extension-dist + - name: Free up disk space run: | echo "before:"; df -h / /mnt | tail -2 @@ -90,6 +145,7 @@ jobs: -w /build \ -e ISO_VERSION="${BUILD_VERSION}" \ -e VSCODE_VERSION="${VSCODE_VERSION}" \ + -e VSCODEOS_EXTENSION_PREBUILT=/build/extension-dist \ archlinux:latest \ bash -euo pipefail -c ' # The keyring goes first and on its own: a base image older than @@ -156,8 +212,11 @@ jobs: raspberrypi: name: Raspberry Pi image - needs: version - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["both","raspberrypi"]'), github.event.inputs.targets) + needs: [version, extension] + if: >- + github.event_name != 'pull_request' + && (github.event_name != 'workflow_dispatch' + || contains(fromJSON('["both","raspberrypi"]'), github.event.inputs.targets)) # A native aarch64 runner means the build can chroot into the image # directly instead of emulating every command through qemu. runs-on: ubuntu-24.04-arm @@ -165,6 +224,12 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Fetch the prebuilt extension + uses: actions/download-artifact@v8 + with: + name: vscodeos-core-extension + path: extension-dist + - name: Install image tooling run: | sudo apt-get update -qq @@ -176,6 +241,7 @@ jobs: env: BUILD_VERSION: ${{ needs.version.outputs.version }} VSCODE_VERSION: ${{ github.event.inputs.vscode_version || 'latest' }} + VSCODEOS_EXTENSION_PREBUILT: ${{ github.workspace }}/extension-dist run: sudo -E ./rpi/build-image.sh -v "${BUILD_VERSION}" -o "${PWD}/out" -w /mnt/vscodeos-rpi - name: Summarise @@ -219,6 +285,10 @@ jobs: steps: - uses: actions/download-artifact@v8 with: + # Images only. Without the filter the extension bundle would be + # downloaded too, and `gh release create artifacts/*` below would + # publish it as a release asset. + pattern: VSCodeOS-* path: artifacts merge-multiple: true diff --git a/.gitignore b/.gitignore index adf5225..df33758 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,13 @@ # pacman sync databases, dropped in the working tree by a build run from here *.db +# Extension build output. The sources are committed; the bundles are not. +/extension/node_modules/ +/extension/dist/ +/extension/out/ +/extension/media/dist/ +/extension-dist/ + # Editor / OS noise .DS_Store *.swp diff --git a/README.md b/README.md index 14c5431..264b316 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ A Linux distribution whose entire user interface is Visual Studio Code. It is a minimal Arch Linux base with the official Microsoft build of VS Code -layered on top. There is no desktop environment, no taskbar and no application -menu: the machine boots, logs in and puts the editor on screen fullscreen, and -that is the whole system. Everything else — package management, networking, -git, compilers — is reached through the editor's integrated terminal. +layered on top. There is no desktop environment and no application menu: the +machine boots, logs in and puts the editor on screen fullscreen, and that is the +whole system. What a desktop would normally give you — a tray with a clock and a +power button, a task manager, a file manager, a browser, a handful of small apps +— is supplied by **VsCodeOsCore**, an extension built into the editor itself. ``` power on @@ -30,6 +31,8 @@ Two images are published for every release, sharing the same kiosk: | **Base** | Arch Linux, built with `archiso` | Arch Linux ARM | | **Editor** | Official VS Code, `linux-x64` | Official VS Code, `linux-arm64` | | **Session** | Xorg + Openbox kiosk — no panel, no launcher, no desktop | same | +| **Shell** | VsCodeOsCore, built into the editor | same | +| **Browser** | Chromium | Chromium | | **Toolchain** | git, git-lfs, Node.js, Python, base-devel, Docker | git, Node.js, Python, base-devel | | **Boot** | UEFI (x64 and ia32) and legacy BIOS, one hybrid image | Pi firmware from a FAT partition | | **Getting it onto a machine** | live medium + `vscodeos-install` | flash the image; it *is* the system | @@ -51,6 +54,48 @@ and Arch Linux ARM repositories as they stand on the day of the release, so each release is a current system rather than a frozen one. The `.packages.txt` file published next to each image lists exactly which versions it shipped. +### The desktop shell + +The editor has no menu bar and no window controls, so everything a desktop needs +lives in **VsCodeOsCore** — a VS Code extension that ships *inside* the editor +rather than being installed from the Marketplace. Its source is in +[`extension/`](extension/), and it adds: + +- **A tray**, at the right end of the status bar. Left to right: now playing, + battery, volume, network, the clock and date, and the power button in the + corner. Each one opens a flyout in the bottom panel — a Windows-style card + that rises directly above the item you clicked. +- **Power** — sleep, restart, shut down and log out, with a confirmation. +- **Calendar** — a month grid with today highlighted, on the clock. +- **Quick settings** — Wi-Fi, Bluetooth, airplane mode, energy saver, night + light and accessibility, plus brightness and volume sliders. Tiles hide + themselves when the hardware is not there, rather than showing a dead switch. +- **Network** — scan, connect with a password, and switch between saved + connections. +- **Task Manager**, in the activity bar — processes with CPU and memory, per-core + meters, load average, uptime and CPU temperature, sortable and filterable, with + End task. +- **Files** — a graphical file explorer with a places sidebar, grid and list + views, rename, trash, copy and paste. Text opens in the editor; everything else + goes to `xdg-open`. +- **Music** — transport controls for whatever is playing, over MPRIS, plus + one-click launchers that open Spotify Web and YouTube Music as their own + browser windows. +- **Apps** — Calculator, Notepad, Paint, Screenshot and Voice Recorder. `VS Code + OS: All Apps…` in the command palette (**Ctrl** + **Shift** + **P**) lists + everything. + +Two honest limits, both imposed by VS Code rather than by this project: +**Spotify audio cannot play inside the editor** (VS Code's Electron ships no +Widevine, so the Web Playback SDK cannot work — which is exactly why the player +controls a real browser window instead), and **Microsoft Edge is not on either +image** (it is AUR-only on Arch, and Microsoft publishes no ARM64 Linux build at +all, so the Pi could never have matched). The browser launcher prefers +`microsoft-edge-stable` if you install it yourself, and falls back to Chromium. + +Every part of the shell can be turned off individually in settings under +`vscodeos.*`; [`extension/README.md`](extension/README.md) has the details. + ### Supported Raspberry Pi models 64-bit boards only: **Pi 5, Pi 4, Pi 400, CM4, Pi 3/3+ and Zero 2 W**. A 4 GB @@ -154,13 +199,21 @@ VSCODEOS_RESPAWN=1 # 0 = do not relaunch when VS Code exits ```bash sudo pacman -Syu # update the Arch base sudo vscodeos-update-code # update VS Code itself (it is not a pacman package) -nmtui # join a Wi-Fi network +nmtui # join a Wi-Fi network (or use the tray) code ~/Projects/thing # open something in the running editor ``` Extensions, settings sync and Marketplace sign-in all work normally; `gnome-keyring` is started by the session so credentials persist. +`vscodeos-update-code` replaces the whole editor tree, which is where the shell +lives, so it reinstalls VsCodeOsCore afterwards. To do that by hand — say after +unpacking a VS Code build yourself: + +```bash +sudo vscodeos-install-extensions --force +``` + ## Building the images ### With GitHub Actions @@ -183,15 +236,26 @@ where you can pick one target or both and pin a specific VS Code version. A manual run uploads the images as build artifacts and does not create a release. +Pull requests run the desktop shell's typecheck, tests and bundle build, and +nothing else — a couple of minutes, and the only part of a build that fails on +a code mistake rather than on a mirror or a runner. The images themselves are +two-hour builds producing gigabyte artifacts, so they stay on tags and manual +runs. + ### Locally +Both builds compile the desktop shell first, so a local build needs **Node.js** +on the machine doing the building — `pacman -S nodejs npm`, or whatever your +distribution calls it. The container one-liner below installs it itself. Pass +`VSCODEOS_SKIP_EXTENSION=1` to leave the shell out and build the bare kiosk. + **x86-64 ISO** — needs Docker (or an Arch host with `archiso`) and roughly 20 GB of free disk space: ```bash docker run --rm --privileged --pull always -v "$PWD:/build" -w /build archlinux:latest \ bash -c 'pacman -Sy --noconfirm --needed archlinux-keyring && - pacman -Syu --noconfirm --needed archiso git grub && + pacman -Syu --noconfirm --needed archiso git grub nodejs npm && ./scripts/build-iso.sh -v 1.0.0' ``` @@ -203,7 +267,7 @@ qemu-system-x86_64 -m 4G -enable-kvm -cdrom out/VSCodeOS-1.0.0-x86_64.iso laptop, an ARM VM), because the build chroots into the image it is assembling: ```bash -sudo apt-get install -y libarchive-tools dosfstools e2fsprogs xz-utils util-linux +sudo apt-get install -y libarchive-tools dosfstools e2fsprogs xz-utils util-linux nodejs npm sudo ./rpi/build-image.sh -v 1.0.0 ``` @@ -223,15 +287,23 @@ stack — when it finishes. ## How the repository is laid out ``` +extension/ VsCodeOsCore, the desktop shell + src/ extension host: sys/, statusbar/, views/, apps/ + media/ one webview bundle per page, plus the stylesheet + (see extension/README.md) + rootfs-common/ the kiosk, shared by both images etc/passwd, group, shadow the kiosk account (uid 1000) etc/systemd/system/ autologin on tty1, enabled services etc/X11/xorg.conf.d/ kiosk hardening (DontVTSwitch, DontZap) + etc/polkit-1/rules.d/ power and NetworkManager without a password + etc/udev/rules.d/ backlight writable by the `video` group etc/default/vscodeos kiosk settings etc/skel/ the kiosk user's home: .xinitrc, openbox rules, VS Code settings and keybindings usr/local/bin/vscodeos-kiosk the session supervisor usr/local/bin/vscodeos-update-code + usr/local/bin/vscodeos-install-extensions archiso/ x86-64 only profiledef.sh archiso profile: image name, boot modes @@ -251,6 +323,7 @@ rpi/ Raspberry Pi only scripts/ build-iso.sh assembles the profile and runs mkarchiso fetch-vscode.sh downloads and stages VS Code (x64 or arm64) + build-extension.sh bundles VsCodeOsCore for both images pkg-versions.sh summarises a build's package manifest .github/workflows/build-iso.yml tag -> both images -> one GitHub release ``` @@ -260,6 +333,18 @@ Details worth knowing if you are modifying it: - **The kiosk is shared, the plumbing is not.** Everything in `rootfs-common/` is copied into both images; anything architecture-specific lives in `archiso/airootfs/` or `rpi/overlay/`. +- **The shell is a built-in extension, not a Marketplace one.** Both builds stage + it at `/usr/share/vscodeos/extensions/`, then `vscodeos-install-extensions` + copies it into `/opt/visual-studio-code/resources/app/extensions/`. That + directory is a plain scan — no `extensions.json`, no version check — so a + VS Code update can never decide the desktop is incompatible and disable it. It + does, however, replace the whole tree, which is why `vscodeos-update-code` + re-runs the installer. +- **The extension is built once, for both images.** It is architecture-neutral + JavaScript, so CI has a separate `extension` job and passes the result to both + image jobs via `VSCODEOS_EXTENSION_PREBUILT`. Local builds compile it on the + spot and cache the result; `VSCODEOS_SKIP_EXTENSION=1` leaves it out when you + are only iterating on the OS. - **Boot menus are not vendored.** `build-iso.sh` copies `syslinux/`, `grub/` and `efiboot/` out of the `archiso` package installed in the build environment and rebrands the labels, so the boot configuration always matches diff --git a/archiso/packages.x86_64 b/archiso/packages.x86_64 index bdf5900..1a3a924 100644 --- a/archiso/packages.x86_64 +++ b/archiso/packages.x86_64 @@ -58,6 +58,23 @@ openssh wget curl ca-certificates +# The quick-settings Bluetooth tile hides itself when there is no adapter, but +# without bluez there is no bluetoothctl to ask in the first place. +bluez +bluez-utils + +# Web browser. +# +# Chromium rather than Microsoft Edge: Edge is AUR-only on Arch, so neither +# build's `pacman -S` flow can install it, and Microsoft publishes no ARM64 +# Linux build at all - the Pi image could never have matched. VsCodeOsCore +# prefers microsoft-edge-stable at run time, so installing it from the AUR is +# enough to make every launcher in the shell use it instead. +# +# This is the single largest thing on this list after the kernel and VS Code +# itself (~600 MiB installed, ~180 MiB once squashfs has had it), so it is the +# first candidate to drop if the ISO ever creeps back over the 2 GiB limit. +chromium # Filesystems / storage helpers gvfs @@ -89,6 +106,9 @@ vulkan-radeon vulkan-swrast xdg-utils xdg-user-dirs +# Screen capture for the shell's screenshot tool. Nothing inside a VS Code +# webview can read the screen, so this has to be a real X client. +scrot # Fonts ttf-dejavu @@ -105,6 +125,10 @@ pipewire-alsa pipewire-pulse wireplumber alsa-utils +# MPRIS transport control for the shell's music player. Chromium exports MPRIS +# for whatever is playing in it, which is how Spotify Web and YouTube Music end +# up controllable from the status bar. +playerctl # Input / laptop bits libinput diff --git a/archiso/profiledef.sh b/archiso/profiledef.sh index 21affe4..ee64cd2 100755 --- a/archiso/profiledef.sh +++ b/archiso/profiledef.sh @@ -25,6 +25,7 @@ file_permissions=( ["/usr/local/bin/vscodeos-install"]="0:0:755" ["/usr/local/bin/vscodeos-kiosk"]="0:0:755" ["/usr/local/bin/vscodeos-update-code"]="0:0:755" + ["/usr/local/bin/vscodeos-install-extensions"]="0:0:755" ["/usr/local/bin/code"]="0:0:755" ["/etc/sudoers.d/vscodeos"]="0:0:0440" ) diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..2ea03fa --- /dev/null +++ b/extension/README.md @@ -0,0 +1,120 @@ +# VsCodeOsCore + +The VS Code OS desktop shell. VS Code OS has no desktop environment — the editor +*is* the user interface — so this extension supplies the parts of a desktop the +editor does not have. + +It ships as a **built-in extension** in both images (see +[Packaging](#packaging)), so it is present on first boot and cannot be +accidentally uninstalled. + +## What it adds + +| | | +| --- | --- | +| **Tray** | Power button, clock and date, battery, volume, network and now-playing, at the right end of the status bar | +| **Flyouts** | Power, calendar, quick settings, volume mixer, network picker and music player | +| **Task Manager** | Processes with CPU/RAM, per-core meters, load, uptime and thermals, in the activity bar | +| **Files** | A graphical file explorer: places sidebar, grid/list, rename, trash, copy/paste, open with `xdg-open` | +| **Music** | MPRIS transport for whatever is playing, plus launchers for Spotify Web and YouTube Music | +| **Browser** | Launches Edge → Chromium → Firefox, whichever is installed | +| **Apps** | Calculator, Notepad, Paint, Screenshot and Voice Recorder | + +Every feature is behind a `vscodeos..enabled` setting, all defaulting to +on. `VS Code OS: All Apps…` in the command palette lists everything. + +## Developing + +```bash +cd extension +npm install +npm run watch # esbuild in watch mode +``` + +Then press F5 in VS Code to launch an Extension Development Host. Most +of it works on any Linux desktop; the `sys/*` modules degrade to a hidden status +bar item when the binary they need is missing, so a machine without `nmcli` or +`wpctl` just shows fewer tray items rather than erroring. + +```bash +npm run typecheck # tsc --noEmit +npm test # node:test, no framework to install +npm run package # production bundles +``` + +CI runs all three before either image build starts. The tests cover the two +self-contained modules — the calculator's expression evaluator and the +formatters — because everything else here talks to `/proc`, `nmcli` or the +VS Code API and is verified by running the thing. That is a narrow surface, but +it is the surface where a mistake is invisible: unary minus alone accounted for +three real bugs (`5 − −3`, `2 × −3`, and the `±` key emitting an ASCII hyphen +the tokenizer did not handle). + +## Layout + +``` +src/ + extension.ts activate(): wires everything, one DisposableStore + sys/ the only code that touches the machine + statusbar/ the tray, and the priority ladder that orders it + views/ flyout (panel) and task manager (activity bar) providers + apps/ file explorer, mini-apps, panel plumbing + webview/ HTML shell + the host↔webview message types +media/ + src/ one TypeScript entry point per page, shared code in src/lib + css/ one stylesheet, all colours from --vscode-* variables + icons/ container and panel icons +``` + +`src/webview/protocol.ts` is imported by both sides, so a change to a message +shape is a compile error in the webview that consumes it. + +## Packaging + +`scripts/build-extension.sh` (in the repo root) produces the tree that ships: + +``` +/usr/share/vscodeos/extensions/vscodeos-core/ +``` + +and `vscodeos-install-extensions` copies it into + +``` +/opt/visual-studio-code/resources/app/extensions/vscodeos-core/ +``` + +That folder is VS Code's built-in extension directory. It is scanned as a plain +directory — no `extensions.json`, no marketplace metadata — and built-ins skip +the engine version check entirely, so a VS Code update can never mark the shell +incompatible. The trade is that `vscodeos-update-code` replaces the whole app +tree, so it re-runs `vscodeos-install-extensions --force` afterwards. + +Being a built-in has one hard consequence: **stable API only.** Proposed APIs +for built-in extensions are gated on Microsoft's `product.json`, which we do not +control. + +## Things this cannot do, and why + +These are VS Code and Electron limits, not missing work: + +- **No popup anchored to a status bar item.** There is no such API. The flyouts + are a webview view in the bottom panel, which is why they open above the + status bar; the panel's height is workbench layout state with no API, so a + flyout opens at whatever height the panel was last left at. +- **The power button is only rightmost because of a setting.** The notifications + bell registers at `NEGATIVE_INFINITY` and extension priorities are clamped to + `-Number.MAX_VALUE`, so no extension can outrank it. The images ship + `"workbench.notifications.position": "top-right"`, which removes the bell from + the status bar. Without it the power button sits second from the right. +- **No microphone in a webview.** VS Code's Electron main process omits `media` + from the permissions it grants webviews, so `getUserMedia({audio:true})` is + denied outright. The recorder is a `pw-record` subprocess with a webview UI. +- **Spotify audio cannot play inside VS Code.** Stock Electron ships no Widevine + CDM, so the Web Playback SDK cannot decrypt anything, and `open.spotify.com` + refuses to be framed. This is why the music player controls real players over + MPRIS and launches the services as browser app windows — Chromium exports + MPRIS, so playback there is fully controllable from the tray. +- **Root-owned processes cannot be ended directly.** There is no polkit + authentication agent in the kiosk session, so `pkexec` has nothing to prompt + with. End task on another user's process opens a terminal with + `sudo kill -9 ` ready to run instead. diff --git a/extension/esbuild.mjs b/extension/esbuild.mjs new file mode 100644 index 0000000..497b6ce --- /dev/null +++ b/extension/esbuild.mjs @@ -0,0 +1,62 @@ +// Bundler for VsCodeOsCore. +// +// Two very different targets come out of one config: +// +// src/extension.ts -> dist/extension.js Node/CJS, runs in the extension host +// media/src/*.ts -> media/dist/*.js browser/IIFE, runs inside each webview +// +// Nothing is left in node_modules: the shipped extension is package.json, dist/ +// and media/, which keeps the payload that goes into every image small. + +import * as esbuild from 'esbuild'; +import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = dirname(fileURLToPath(import.meta.url)); +const production = process.argv.includes('--production'); +const watch = process.argv.includes('--watch'); + +// One entry point per page. Shared code lives in media/src/lib and is inlined +// into each bundle rather than emitted as a script of its own. +const webviewEntries = readdirSync(join(root, 'media', 'src'), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) + .map((entry) => join(root, 'media', 'src', entry.name)); + +/** @type {import('esbuild').BuildOptions} */ +const common = { + bundle: true, + minify: production, + sourcemap: production ? false : 'inline', + logLevel: 'info', +}; + +/** @type {import('esbuild').BuildOptions[]} */ +const configs = [ + { + ...common, + entryPoints: [join(root, 'src', 'extension.ts')], + outfile: join(root, 'dist', 'extension.js'), + platform: 'node', + target: 'node18', + format: 'cjs', + // Supplied by the extension host at run time, never bundled. + external: ['vscode'], + }, + { + ...common, + entryPoints: webviewEntries, + outdir: join(root, 'media', 'dist'), + platform: 'browser', + target: 'es2021', + format: 'iife', + }, +]; + +if (watch) { + const contexts = await Promise.all(configs.map((c) => esbuild.context(c))); + await Promise.all(contexts.map((c) => c.watch())); + console.log('watching…'); +} else { + await Promise.all(configs.map((c) => esbuild.build(c))); +} diff --git a/extension/media/css/vscodeos.css b/extension/media/css/vscodeos.css new file mode 100644 index 0000000..8025b13 --- /dev/null +++ b/extension/media/css/vscodeos.css @@ -0,0 +1,989 @@ +/* VS Code OS shell styling. + * + * Every colour comes from a --vscode-* variable, so the shell follows whatever + * theme the user picks instead of shipping its own palette. The flyouts are laid + * out as a card pinned to the bottom-right of the panel, so they read as rising + * out of the tray item that opened them. */ + +:root { + --vscodeos-radius: 8px; + --vscodeos-gap: 8px; + --vscodeos-card-bg: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + --vscodeos-card-border: var(--vscode-widget-border, var(--vscode-editorWidget-border, transparent)); + --vscodeos-tile-bg: var(--vscode-button-secondaryBackground, rgba(127, 127, 127, 0.14)); + --vscodeos-tile-fg: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + --vscodeos-accent: var(--vscode-button-background, #0078d4); + --vscodeos-accent-fg: var(--vscode-button-foreground, #ffffff); + --vscodeos-muted: var(--vscode-descriptionForeground); +} + +* { + box-sizing: border-box; +} + +body.vscodeos { + margin: 0; + padding: 0; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size, 13px); + color: var(--vscode-foreground); + background: transparent; + -webkit-font-smoothing: antialiased; +} + +.icon { + flex: 0 0 auto; + vertical-align: middle; +} + +button { + font-family: inherit; + font-size: inherit; + color: inherit; + border: none; + background: none; + cursor: pointer; +} + +button:focus-visible, +[tabindex]:focus-visible, +input:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +input[type='text'], +input[type='password'], +input[type='search'], +textarea, +select { + font-family: inherit; + font-size: inherit; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + padding: 4px 8px; +} + +/* ---------------------------------------------------------------- flyouts */ + +.flyout-host { + display: flex; + justify-content: flex-end; + align-items: flex-end; + min-height: 100%; + padding: 10px 14px; +} + +.flyout { + width: min(400px, 100%); + max-height: 100%; + overflow-y: auto; + padding: 14px; + border-radius: var(--vscodeos-radius); + border: 1px solid var(--vscodeos-card-border); + background: var(--vscodeos-card-bg); + box-shadow: 0 8px 26px rgba(0, 0, 0, 0.34); + animation: flyout-in 130ms ease-out; +} + +.flyout.wide { + width: min(560px, 100%); +} + +@keyframes flyout-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +@media (prefers-reduced-motion: reduce) { + .flyout { animation: none; } +} + +.flyout-title { + font-size: 1.05em; + font-weight: 600; + margin: 0 0 10px; +} + +.flyout-note { + color: var(--vscodeos-muted); + font-size: 0.92em; + margin: 8px 2px 0; +} + +/* Quick-settings grid, three across, exactly like the Windows tray. */ +.tiles { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--vscodeos-gap); +} + +.tile { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-height: 74px; + padding: 10px; + border-radius: 6px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + text-align: left; + transition: background 80ms ease; +} + +.tile:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.tile.on { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.tile.on:hover { + background: var(--vscode-button-hoverBackground, var(--vscodeos-accent)); +} + +.tile-label { + font-size: 0.86em; + line-height: 1.25; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.tile-sub { + opacity: 0.75; + font-size: 0.94em; +} + +.slider-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; +} + +.slider-row .icon { + opacity: 0.85; +} + +.slider-value { + min-width: 3.2em; + text-align: right; + color: var(--vscodeos-muted); + font-variant-numeric: tabular-nums; +} + +input[type='range'] { + flex: 1; + appearance: none; + height: 4px; + border-radius: 2px; + background: var(--vscode-scrollbarSlider-background, rgba(127, 127, 127, 0.4)); + cursor: pointer; +} + +input[type='range']::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--vscodeos-accent); + border: 2px solid var(--vscodeos-card-bg); +} + +/* Power flyout */ +.power-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--vscodeos-gap); +} + +.power-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 18px 10px; + border-radius: 6px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + font-size: 0.95em; +} + +.power-button:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.power-button.danger:hover { + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); +} + +/* Calendar */ +.calendar-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.calendar-title { + font-weight: 600; +} + +.calendar-nav { + display: flex; + gap: 2px; +} + +.calendar-nav button, +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + color: var(--vscode-foreground); +} + +.calendar-nav button:hover, +.icon-button:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.calendar-grid .dow { + text-align: center; + font-size: 0.82em; + color: var(--vscodeos-muted); + padding: 4px 0; +} + +.calendar-day { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.92em; + font-variant-numeric: tabular-nums; +} + +.calendar-day:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + +.calendar-day.other-month { + color: var(--vscodeos-muted); + opacity: 0.55; +} + +.calendar-day.today { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); + font-weight: 600; +} + +.calendar-clock { + text-align: center; + font-size: 1.9em; + font-weight: 300; + font-variant-numeric: tabular-nums; + margin-bottom: 2px; +} + +.calendar-date { + text-align: center; + color: var(--vscodeos-muted); + margin-bottom: 12px; +} + +/* Lists (networks, bluetooth devices, sinks, recordings) */ +.list { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; +} + +.list-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 5px; + text-align: left; +} + +.list-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.list-row.active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.list-main { + flex: 1; + min-width: 0; +} + +.list-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-sub { + font-size: 0.85em; + color: var(--vscodeos-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-row.active .list-sub { + color: inherit; + opacity: 0.8; +} + +.empty { + padding: 18px 4px; + text-align: center; + color: var(--vscodeos-muted); +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 14px 2px 4px; + font-size: 0.82em; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--vscodeos-muted); +} + +/* Now playing */ +.now-playing { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 10px; +} + +.album-art { + width: 62px; + height: 62px; + border-radius: 5px; + object-fit: cover; + background: var(--vscodeos-tile-bg); + flex: 0 0 auto; +} + +.transport { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin: 8px 0 4px; +} + +.transport button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + color: var(--vscode-foreground); +} + +.transport button:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + +.transport .primary { + width: 42px; + height: 42px; + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.progress-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82em; + color: var(--vscodeos-muted); + font-variant-numeric: tabular-nums; +} + +/* ----------------------------------------------------------- app chrome */ + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +.toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--vscode-panel-border, transparent); + flex: 0 0 auto; + flex-wrap: wrap; +} + +.toolbar .spacer { + flex: 1; +} + +.button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: 4px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + white-space: nowrap; +} + +.button:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.button.primary { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.button.primary:hover { + background: var(--vscode-button-hoverBackground, var(--vscodeos-accent)); +} + +.button:disabled { + opacity: 0.45; + cursor: default; +} + +.body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.status { + flex: 0 0 auto; + padding: 5px 10px; + border-top: 1px solid var(--vscode-panel-border, transparent); + color: var(--vscodeos-muted); + font-size: 0.88em; + display: flex; + gap: 14px; +} + +.error-banner { + margin: 8px 10px; + padding: 8px 10px; + border-radius: 4px; + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + border: 1px solid var(--vscode-inputValidation-errorBorder, transparent); +} + +/* ------------------------------------------------------- task manager */ + +.meters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; + padding: 10px; +} + +.meter { + padding: 10px 12px; + border-radius: 6px; + background: var(--vscodeos-tile-bg); +} + +.meter-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.meter-value { + margin-left: auto; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.meter-sub { + font-size: 0.85em; + color: var(--vscodeos-muted); + margin-top: 6px; +} + +.bar { + height: 5px; + border-radius: 3px; + background: var(--vscode-scrollbarSlider-background, rgba(127, 127, 127, 0.3)); + overflow: hidden; +} + +.bar > span { + display: block; + height: 100%; + background: var(--vscodeos-accent); + transition: width 200ms linear; +} + +.bar.warn > span { + background: var(--vscode-charts-red, #f14c4c); +} + +.cores { + display: flex; + gap: 3px; + margin-top: 8px; + height: 26px; + align-items: flex-end; +} + +.cores > span { + flex: 1; + background: var(--vscodeos-accent); + border-radius: 1px 1px 0 0; + min-height: 2px; + transition: height 200ms linear; +} + +table.processes { + width: 100%; + border-collapse: collapse; + font-size: 0.92em; +} + +table.processes th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + font-weight: 600; + padding: 6px 10px; + background: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-panel-border, rgba(127, 127, 127, 0.3)); + cursor: pointer; + white-space: nowrap; +} + +table.processes th:hover { + color: var(--vscode-textLink-foreground); +} + +table.processes td { + padding: 4px 10px; + border-bottom: 1px solid var(--vscode-panel-border, rgba(127, 127, 127, 0.12)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 320px; +} + +table.processes tbody tr:hover { + background: var(--vscode-list-hoverBackground); +} + +table.processes tbody tr.selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.hot { + color: var(--vscode-charts-red, #f14c4c); + font-weight: 600; +} + +/* ------------------------------------------------------ file explorer */ + +.breadcrumb { + display: flex; + align-items: center; + gap: 2px; + flex-wrap: wrap; + flex: 1; + min-width: 0; +} + +.crumb { + padding: 3px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.crumb:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + +.explorer { + display: flex; + height: 100%; + min-height: 0; +} + +.places { + flex: 0 0 190px; + overflow-y: auto; + padding: 8px; + border-right: 1px solid var(--vscode-panel-border, transparent); +} + +.files { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 8px; +} + +.files.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); + gap: 4px; + align-content: start; +} + +.file-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 10px 6px; + border-radius: 5px; + text-align: center; +} + +.file-tile .name { + font-size: 0.85em; + line-height: 1.25; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.file-tile:hover, +.file-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.file-tile.selected, +.file-row.selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.file-row { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 8px; + border-radius: 4px; +} + +.file-row .name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-row .size, +.file-row .date { + color: var(--vscodeos-muted); + font-size: 0.86em; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.file-row .size { + width: 78px; + text-align: right; +} + +.file-row .date { + width: 150px; + text-align: right; +} + +.folder-icon { + color: var(--vscode-charts-blue, #75beff); +} + +/* --------------------------------------------------------- calculator */ + +.calc { + max-width: 340px; + margin: 0 auto; + padding: 14px; +} + +.calc-display { + padding: 14px 12px; + border-radius: 6px; + background: var(--vscodeos-tile-bg); + text-align: right; + margin-bottom: 12px; +} + +.calc-expression { + min-height: 1.3em; + color: var(--vscodeos-muted); + font-size: 0.92em; + word-break: break-all; +} + +.calc-result { + font-size: 2.1em; + font-weight: 300; + font-variant-numeric: tabular-nums; + word-break: break-all; +} + +.calc-keys { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +.calc-keys.scientific { + grid-template-columns: repeat(5, 1fr); +} + +.key { + padding: 14px 0; + border-radius: 5px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + font-size: 1.05em; +} + +.key:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.key.op { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.16)); +} + +.key.equals { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.key.span-2 { + grid-column: span 2; +} + +.calc-history { + margin-top: 14px; + max-height: 160px; + overflow-y: auto; + font-size: 0.9em; +} + +.calc-history div { + padding: 4px 2px; + color: var(--vscodeos-muted); + text-align: right; + border-bottom: 1px solid var(--vscode-panel-border, rgba(127, 127, 127, 0.12)); +} + +/* -------------------------------------------------------------- paint */ + +.paint-wrap { + display: flex; + height: 100%; + min-height: 0; +} + +.paint-tools { + flex: 0 0 62px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + border-right: 1px solid var(--vscode-panel-border, transparent); + overflow-y: auto; +} + +.tool { + display: flex; + align-items: center; + justify-content: center; + height: 40px; + border-radius: 5px; + background: var(--vscodeos-tile-bg); +} + +.tool:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.tool.active { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.canvas-area { + flex: 1; + min-width: 0; + overflow: auto; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: var(--vscode-editor-background); +} + +#canvas { + background: #ffffff; + box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3); + cursor: crosshair; + touch-action: none; + max-width: 100%; +} + +.swatches { + display: grid; + grid-template-columns: repeat(8, 18px); + gap: 3px; +} + +.swatch { + width: 18px; + height: 18px; + border-radius: 3px; + border: 1px solid rgba(127, 127, 127, 0.4); + padding: 0; +} + +.swatch.active { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +/* ------------------------------------------------ notepad / screenshot */ + +#notepad-text { + width: 100%; + height: 100%; + border: none; + border-radius: 0; + resize: none; + padding: 12px 14px; + font-family: var(--vscode-editor-font-family); + font-size: var(--vscode-editor-font-size, 14px); + line-height: 1.5; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); +} + +#notepad-text:focus { + outline: none; +} + +.shot-preview, +.paint-preview { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + min-height: 0; +} + +.shot-preview img { + max-width: 100%; + max-height: 68vh; + border-radius: 4px; + box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3); +} + +.field { + display: flex; + align-items: center; + gap: 6px; +} + +.field label { + color: var(--vscodeos-muted); + font-size: 0.9em; +} + +/* ----------------------------------------------------------- recorder */ + +.record-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 28px 16px 18px; +} + +.record-button { + width: 84px; + height: 84px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--vscode-charts-red, #f14c4c); + color: #ffffff; +} + +.record-button.recording { + animation: pulse 1.6s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(241, 76, 76, 0.5); } + 50% { box-shadow: 0 0 0 14px rgba(241, 76, 76, 0); } +} + +@media (prefers-reduced-motion: reduce) { + .record-button.recording { animation: none; } +} + +.record-time { + font-size: 2em; + font-weight: 300; + font-variant-numeric: tabular-nums; +} + +audio { + width: 100%; +} diff --git a/extension/media/icons/calculator.svg b/extension/media/icons/calculator.svg new file mode 100644 index 0000000..cd95347 --- /dev/null +++ b/extension/media/icons/calculator.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/media/icons/files.svg b/extension/media/icons/files.svg new file mode 100644 index 0000000..acc264a --- /dev/null +++ b/extension/media/icons/files.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/media/icons/notepad.svg b/extension/media/icons/notepad.svg new file mode 100644 index 0000000..89f59e4 --- /dev/null +++ b/extension/media/icons/notepad.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/media/icons/paint.svg b/extension/media/icons/paint.svg new file mode 100644 index 0000000..c87a70b --- /dev/null +++ b/extension/media/icons/paint.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/media/icons/recorder.svg b/extension/media/icons/recorder.svg new file mode 100644 index 0000000..b9e29d6 --- /dev/null +++ b/extension/media/icons/recorder.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/media/icons/screenshot.svg b/extension/media/icons/screenshot.svg new file mode 100644 index 0000000..f467426 --- /dev/null +++ b/extension/media/icons/screenshot.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/media/icons/system.svg b/extension/media/icons/system.svg new file mode 100644 index 0000000..9a8e7f2 --- /dev/null +++ b/extension/media/icons/system.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/extension/media/src/calculator.ts b/extension/media/src/calculator.ts new file mode 100644 index 0000000..3104f84 --- /dev/null +++ b/extension/media/src/calculator.ts @@ -0,0 +1,174 @@ +// Calculator: standard and scientific, keyboard-driven. +// +// The expression is evaluated by a small shunting-yard parser rather than by +// handing a string to the JS engine - a webview that eval()s whatever is in a +// text field is exactly the thing a CSP is there to prevent. + +import { clear, h, post, root, vscode } from './lib/dom'; +import { evaluate, isError } from './lib/calc'; +import { icon } from './lib/icons'; + +interface Persisted { + scientific: boolean; + history: string[]; +} + +const saved = vscode.getState() ?? { scientific: false, history: [] }; +let scientific = saved.scientific; +let history: string[] = saved.history; +let expression = ''; +let result = '0'; + +const expressionEl = h('div', { class: 'calc-expression' }); +const resultEl = h('div', { class: 'calc-result' }, '0'); +const keysEl = h('div', { class: 'calc-keys' }); +const historyEl = h('div', { class: 'calc-history' }); + +const modeButton = h('button', { + class: 'button', + on: { + click: () => { + scientific = !scientific; + persist(); + renderKeys(); + modeButton.replaceChildren(scientific ? 'Standard' : 'Scientific'); + }, + }, +}, scientific ? 'Standard' : 'Scientific'); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + h('span', { html: icon('grid', 16) }), + h('span', {}, 'Calculator'), + h('span', { class: 'spacer' }), + modeButton, + h('button', { + class: 'button', + on: { click: () => { history = []; persist(); renderHistory(); } }, + }, 'Clear history'), + ), + h('div', { class: 'body' }, + h('div', { class: 'calc' }, + h('div', { class: 'calc-display' }, expressionEl, resultEl), + keysEl, + historyEl, + ), + ), +)); + +const STANDARD: [string, string?][] = [ + ['C'], ['⌫'], ['%'], ['÷'], + ['7'], ['8'], ['9'], ['×'], + ['4'], ['5'], ['6'], ['−'], + ['1'], ['2'], ['3'], ['+'], + ['±'], ['0'], ['.'], ['='], +]; + +const SCIENTIFIC: [string, string?][] = [ + ['C'], ['⌫'], ['('], [')'], ['÷'], + ['sin'], ['cos'], ['tan'], ['^'], ['×'], + ['ln'], ['log'], ['√'], ['%'], ['−'], + ['7'], ['8'], ['9'], ['π'], ['+'], + ['4'], ['5'], ['6'], ['e'], ['='], + ['1'], ['2'], ['3'], ['0'], ['.'], +]; + +function renderKeys(): void { + keysEl.className = `calc-keys${scientific ? ' scientific' : ''}`; + clear(keysEl); + for (const [label] of scientific ? SCIENTIFIC : STANDARD) { + const isOperator = ['÷', '×', '−', '+', '%', '^', '(', ')'].includes(label); + const isFunction = ['sin', 'cos', 'tan', 'ln', 'log', '√', 'π', 'e'].includes(label); + keysEl.append(h('button', { + class: `key${label === '=' ? ' equals' : isOperator || isFunction ? ' op' : ''}`, + on: { click: () => press(label) }, + }, label)); + } +} + +function renderHistory(): void { + clear(historyEl); + for (const entry of history.slice(-12).reverse()) { + historyEl.append(h('div', {}, entry)); + } +} + +function persist(): void { + vscode.setState({ scientific, history }); +} + +function press(key: string): void { + switch (key) { + case 'C': + expression = ''; + result = '0'; + break; + case '⌫': + expression = expression.slice(0, -1); + break; + case '=': { + if (!expression) { + break; + } + const value = evaluate(expression); + result = value; + if (!isError(value)) { + history = [...history, `${expression} = ${value}`].slice(-50); + persist(); + renderHistory(); + expression = value; + } + break; + } + case '±': + // The typographic minus, so the display matches the keypad's '−'. + expression = expression.startsWith('−') ? expression.slice(1) : `−${expression}`; + break; + case 'π': + expression += 'π'; + break; + case 'e': + expression += 'e'; + break; + case 'sin': case 'cos': case 'tan': case 'ln': case 'log': case '√': + expression += `${key}(`; + break; + default: + expression += key; + } + + if (key !== '=' && expression) { + const preview = evaluate(expression); + result = isError(preview) ? result : preview; + } + draw(); +} + +function draw(): void { + expressionEl.textContent = expression || ' '; + resultEl.textContent = result; +} + +// ----------------------------------------------------------------- keyboard + +const KEY_MAP: Record = { + '/': '÷', '*': '×', '-': '−', '+': '+', '%': '%', '^': '^', + Enter: '=', '=': '=', Backspace: '⌫', Escape: 'C', Delete: 'C', + '(': '(', ')': ')', '.': '.', +}; + +document.addEventListener('keydown', (event) => { + if (/^\d$/.test(event.key)) { + press(event.key); + } else if (event.key in KEY_MAP) { + press(KEY_MAP[event.key]); + } else { + return; + } + event.preventDefault(); +}); + +renderKeys(); +renderHistory(); +draw(); +post({ type: 'ready' }); diff --git a/extension/media/src/files.ts b/extension/media/src/files.ts new file mode 100644 index 0000000..eee83c8 --- /dev/null +++ b/extension/media/src/files.ts @@ -0,0 +1,306 @@ +// File explorer front-end: places sidebar, breadcrumb, grid/list view. + +import { append, clear, formatBytes, h, onMessage, post, root, vscode } from './lib/dom'; +import { icon } from './lib/icons'; +import type { FileEntry, HostMessage, Place } from '../../src/webview/protocol'; + +interface Persisted { + view: 'grid' | 'list'; + showHidden: boolean; + sort: 'name' | 'size' | 'modified'; +} + +const saved = vscode.getState() ?? { view: 'grid', showHidden: false, sort: 'name' }; +let view = saved.view; +let showHidden = saved.showHidden; +let sort = saved.sort; + +let currentPath = '/'; +let entries: FileEntry[] = []; +let places: Place[] = []; +let selection = new Set(); +const history: string[] = []; +let historyIndex = -1; + +const breadcrumb = h('div', { class: 'breadcrumb' }); +const placesPane = h('div', { class: 'places' }); +const filesPane = h('div', { class: 'files' }); +const status = h('div', { class: 'status' }); +const banner = h('div', { class: 'error-banner', hidden: true }); + +const backButton = iconButton('chevronLeft', 'Back', () => go(-1)); +const forwardButton = iconButton('chevronRight', 'Forward', () => go(1)); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + backButton, + forwardButton, + iconButton('chevronUp', 'Up one level', () => { + const parent = currentPath.replace(/\/[^/]+\/?$/, '') || '/'; + navigate(parent); + }), + iconButton('refresh', 'Refresh', () => post({ type: 'navigate', path: currentPath })), + breadcrumb, + iconButton('plus', 'New folder', () => post({ type: 'newFolder', path: currentPath })), + iconButton('file', 'New file', () => post({ type: 'newFile', path: currentPath })), + iconButton(view === 'grid' ? 'list' : 'grid', 'Switch view', () => { + view = view === 'grid' ? 'list' : 'grid'; + persist(); + renderFiles(); + }), + iconButton('search', showHidden ? 'Hide hidden files' : 'Show hidden files', () => { + showHidden = !showHidden; + persist(); + renderFiles(); + }), + ), + banner, + h('div', { class: 'body' }, h('div', { class: 'explorer' }, placesPane, filesPane)), + status, +)); + +function iconButton(glyph: string, title: string, onClick: () => void): HTMLButtonElement { + return h('button', { class: 'icon-button', title, html: icon(glyph, 16), on: { click: onClick } }); +} + +onMessage((message) => { + if (message.type !== 'files') { + return; + } + currentPath = message.path; + entries = message.entries; + places = message.places; + selection = new Set(); + + banner.hidden = !message.error; + if (message.error) { + clear(banner).append(h('span', { html: icon('warning', 16) }), ` ${message.error}`); + } + + // Only record a genuine move, so Back does not walk through refreshes. + if (history[historyIndex] !== currentPath) { + history.splice(historyIndex + 1); + history.push(currentPath); + historyIndex = history.length - 1; + } + backButton.disabled = historyIndex <= 0; + forwardButton.disabled = historyIndex >= history.length - 1; + + renderPlaces(); + renderBreadcrumb(); + renderFiles(); +}); + +post({ type: 'ready' }); + +function persist(): void { + vscode.setState({ view, showHidden, sort }); +} + +function navigate(path: string): void { + post({ type: 'navigate', path }); +} + +function go(delta: number): void { + const next = historyIndex + delta; + if (next < 0 || next >= history.length) { + return; + } + historyIndex = next; + // Do not re-record: the message handler sees the same entry it is on. + post({ type: 'navigate', path: history[next] }); +} + +function renderPlaces(): void { + clear(placesPane); + for (const place of places) { + placesPane.append(h('button', { + class: `list-row${place.path === currentPath ? ' active' : ''}`, + on: { click: () => navigate(place.path) }, + }, + h('span', { html: icon(place.icon, 16) }), + h('span', { class: 'list-name' }, place.name), + )); + } +} + +function renderBreadcrumb(): void { + clear(breadcrumb); + const parts = currentPath.split('/').filter(Boolean); + breadcrumb.append(h('button', { class: 'crumb', on: { click: () => navigate('/') } }, '/')); + let accumulated = ''; + for (const part of parts) { + accumulated += `/${part}`; + const target = accumulated; + breadcrumb.append(h('button', { class: 'crumb', on: { click: () => navigate(target) } }, part)); + } +} + +function renderFiles(): void { + const visible = entries + .filter((entry) => showHidden || !entry.hidden) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + if (sort === 'size') { + return b.size - a.size; + } + if (sort === 'modified') { + return b.modified - a.modified; + } + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + }); + + filesPane.className = `files${view === 'grid' ? ' grid' : ''}`; + clear(filesPane); + + if (visible.length === 0) { + filesPane.append(h('div', { class: 'empty' }, 'This folder is empty.')); + } + + for (const entry of visible) { + const glyph = entry.isDirectory ? 'folder' : glyphFor(entry.name); + const activate = (): void => { + if (entry.isDirectory) { + navigate(entry.path); + } else { + post({ type: 'openFile', path: entry.path }); + } + }; + const select = (event: MouseEvent): void => { + if (!event.ctrlKey && !event.metaKey) { + selection.clear(); + } + if (selection.has(entry.path)) { + selection.delete(entry.path); + } else { + selection.add(entry.path); + } + renderFiles(); + renderStatus(); + }; + + const common = { + title: entry.path, + tabIndex: 0, + on: { + click: select as (event: never) => void, + dblclick: activate, + keydown: ((event: KeyboardEvent) => { + if (event.key === 'Enter') { + activate(); + } else if (event.key === 'Delete') { + post({ type: 'delete', paths: [entry.path] }); + } else if (event.key === 'F2') { + post({ type: 'rename', path: entry.path }); + } + }) as (event: never) => void, + contextmenu: ((event: MouseEvent) => { + event.preventDefault(); + selection = new Set([entry.path]); + renderFiles(); + showMenu(event, entry); + }) as (event: never) => void, + }, + }; + + if (view === 'grid') { + filesPane.append(h('div', { + ...common, + class: `file-tile${selection.has(entry.path) ? ' selected' : ''}`, + }, + h('span', { class: entry.isDirectory ? 'folder-icon' : '', html: icon(glyph, 30) }), + h('span', { class: 'name' }, entry.name), + )); + } else { + filesPane.append(h('div', { + ...common, + class: `file-row${selection.has(entry.path) ? ' selected' : ''}`, + }, + h('span', { class: entry.isDirectory ? 'folder-icon' : '', html: icon(glyph, 17) }), + h('span', { class: 'name' }, entry.name), + h('span', { class: 'size' }, entry.isDirectory ? '' : formatBytes(entry.size)), + h('span', { class: 'date' }, entry.modified ? new Date(entry.modified).toLocaleString() : ''), + )); + } + } + renderStatus(); +} + +function renderStatus(): void { + const folders = entries.filter((e) => e.isDirectory && (showHidden || !e.hidden)).length; + const files = entries.filter((e) => !e.isDirectory && (showHidden || !e.hidden)).length; + append(clear(status), + h('span', {}, `${folders} folders, ${files} files`), + selection.size > 0 ? h('span', {}, `${selection.size} selected`) : null, + ); +} + +/** A small context menu, positioned at the pointer. */ +function showMenu(event: MouseEvent, entry: FileEntry): void { + document.querySelector('.context-menu')?.remove(); + + const paths = selection.size > 0 ? [...selection] : [entry.path]; + const item = (label: string, glyph: string, action: () => void): HTMLElement => + h('button', { class: 'list-row', on: { click: () => { menu.remove(); action(); } } }, + h('span', { html: icon(glyph, 15) }), h('span', { class: 'list-name' }, label)); + + const menu = h('div', { + class: 'context-menu flyout', + style: { + position: 'fixed', + left: `${Math.min(event.clientX, window.innerWidth - 220)}px`, + top: `${Math.min(event.clientY, window.innerHeight - 260)}px`, + width: '210px', + padding: '4px', + zIndex: '30', + }, + }, + item(entry.isDirectory ? 'Open' : 'Open in editor', 'open', () => + entry.isDirectory ? navigate(entry.path) : post({ type: 'openFile', path: entry.path })), + item('Open with default app', 'globe', () => post({ type: 'openExternal', path: entry.path })), + item('Reveal in sidebar', 'editor', () => post({ type: 'revealInSidebar', path: entry.path })), + item('Copy', 'file', () => post({ type: 'clipboard', paths, cut: false })), + item('Cut', 'file', () => post({ type: 'clipboard', paths, cut: true })), + item('Paste here', 'save', () => post({ type: 'paste', target: currentPath })), + item('Rename', 'editor', () => post({ type: 'rename', path: entry.path })), + item('Delete', 'trash', () => post({ type: 'delete', paths })), + ); + + document.body.append(menu); + const dismiss = (): void => { + menu.remove(); + document.removeEventListener('click', dismiss); + }; + setTimeout(() => document.addEventListener('click', dismiss), 0); +} + +function glyphFor(name: string): string { + const extension = name.slice(name.lastIndexOf('.')).toLowerCase(); + if (['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg'].includes(extension)) { + return 'image'; + } + if (['.mp3', '.wav', '.flac', '.ogg', '.m4a', '.opus'].includes(extension)) { + return 'music'; + } + if (['.mp4', '.mkv', '.webm', '.mov', '.avi'].includes(extension)) { + return 'video'; + } + if (['.zip', '.gz', '.xz', '.bz2', '.7z', '.tar', '.zst'].includes(extension)) { + return 'disk'; + } + return 'file'; +} + +document.addEventListener('keydown', (event) => { + if (event.key === 'Delete' && selection.size > 0) { + post({ type: 'delete', paths: [...selection] }); + } else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && selection.size > 0) { + post({ type: 'clipboard', paths: [...selection], cut: false }); + } else if ((event.ctrlKey || event.metaKey) && event.key === 'x' && selection.size > 0) { + post({ type: 'clipboard', paths: [...selection], cut: true }); + } else if ((event.ctrlKey || event.metaKey) && event.key === 'v') { + post({ type: 'paste', target: currentPath }); + } +}); diff --git a/extension/media/src/flyout.ts b/extension/media/src/flyout.ts new file mode 100644 index 0000000..5bb474f --- /dev/null +++ b/extension/media/src/flyout.ts @@ -0,0 +1,529 @@ +// The six tray flyouts, drawn as one right-anchored card. +// +// The panel is full width, so the card pins itself to the bottom-right corner: +// that is what makes it read as a flyout rising out of the tray item that was +// clicked, rather than as a docked panel. + +import { append, clear, h, onMessage, post, root, throttle, formatDuration } from './lib/dom'; +import { icon, signalIcon } from './lib/icons'; +import type { FlyoutKind, FlyoutState, HostMessage } from '../../src/webview/protocol'; + +let kind: FlyoutKind = 'quicksettings'; +let state: FlyoutState | undefined; +let busy: string | undefined; +/** Month the calendar is showing; the clock keeps ticking regardless. */ +let calendarMonth = new Date(); + +const host = h('div', { class: 'flyout-host' }); +const card = h('div', { class: 'flyout' }); +host.append(card); +clear(root()).append(host); + +onMessage((message) => { + switch (message.type) { + case 'flyout': + if (message.kind !== kind) { + kind = message.kind; + calendarMonth = new Date(); + busy = undefined; + } + render(); + return; + case 'state': + state = message.state; + busy = undefined; + render(); + return; + case 'scanning': + busy = 'Scanning for networks…'; + render(); + return; + case 'busy': + busy = message.label; + render(); + return; + default: + return; + } +}); + +post({ type: 'ready' }); + +// The clock in the calendar card has to tick on its own; the host only pushes +// state every couple of seconds. +setInterval(() => { + if (kind === 'calendar') { + render(); + } +}, 1000); + +function render(): void { + card.classList.toggle('wide', kind === 'network' || kind === 'music'); + clear(card); + switch (kind) { + case 'power': return renderPower(); + case 'calendar': return renderCalendar(); + case 'volume': return renderVolume(); + case 'network': return renderNetwork(); + case 'music': return renderMusic(); + default: return renderQuickSettings(); + } +} + +function title(text: string): HTMLElement { + return h('h2', { class: 'flyout-title' }, text); +} + +// ------------------------------------------------------------------- power + +type PowerName = 'poweroff' | 'reboot' | 'suspend' | 'logout'; + +function renderPower(): void { + const button = (name: PowerName, label: string, glyph: string, danger = false): HTMLElement => + h('button', { + class: `power-button${danger ? ' danger' : ''}`, + on: { click: () => post({ type: 'power', action: name }) }, + }, h('span', { html: icon(glyph, 26) }), label); + + append(card, + title('Power'), + h('div', { class: 'power-grid' }, + button('poweroff', 'Shut down', 'power', true), + button('reboot', 'Restart', 'restart'), + state?.canSuspend !== false ? button('suspend', 'Sleep', 'sleep') : null, + button('logout', 'Log out', 'logout'), + ), + state?.battery?.present + ? h('p', { class: 'flyout-note' }, + `Battery ${state.battery.level}% · ${state.battery.charging ? 'charging' : state.battery.status.toLowerCase()}`) + : null, + ); +} + +// ---------------------------------------------------------------- calendar + +function renderCalendar(): void { + const now = new Date(); + const shown = calendarMonth; + const first = new Date(shown.getFullYear(), shown.getMonth(), 1); + // Monday-first, matching the reference and most of Europe. + const offset = (first.getDay() + 6) % 7; + const daysInMonth = new Date(shown.getFullYear(), shown.getMonth() + 1, 0).getDate(); + const daysInPrevious = new Date(shown.getFullYear(), shown.getMonth(), 0).getDate(); + + const grid = h('div', { class: 'calendar-grid' }); + for (const day of ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']) { + grid.append(h('div', { class: 'dow' }, day)); + } + + const cell = (day: number, otherMonth: boolean, date: Date): HTMLElement => { + const isToday = date.toDateString() === now.toDateString(); + return h('button', { + class: `calendar-day${otherMonth ? ' other-month' : ''}${isToday ? ' today' : ''}`, + title: date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }), + }, String(day)); + }; + + for (let i = offset - 1; i >= 0; i--) { + const day = daysInPrevious - i; + grid.append(cell(day, true, new Date(shown.getFullYear(), shown.getMonth() - 1, day))); + } + for (let day = 1; day <= daysInMonth; day++) { + grid.append(cell(day, false, new Date(shown.getFullYear(), shown.getMonth(), day))); + } + // Fill the last row so the grid never reflows between months. + const used = offset + daysInMonth; + for (let day = 1; used + day - 1 < Math.ceil(used / 7) * 7; day++) { + grid.append(cell(day, true, new Date(shown.getFullYear(), shown.getMonth() + 1, day))); + } + + const step = (months: number) => () => { + calendarMonth = new Date(shown.getFullYear(), shown.getMonth() + months, 1); + render(); + }; + + card.append( + h('div', { class: 'calendar-clock' }, now.toLocaleTimeString()), + h('div', { class: 'calendar-date' }, + now.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })), + h('div', { class: 'calendar-head' }, + h('button', { + class: 'calendar-title icon-button', + style: { width: 'auto', padding: '0 8px' }, + title: 'Back to this month', + on: { click: () => { calendarMonth = new Date(); render(); } }, + }, shown.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })), + h('div', { class: 'calendar-nav' }, + h('button', { title: 'Previous month', html: icon('chevronUp', 16), on: { click: step(-1) } }), + h('button', { title: 'Next month', html: icon('chevronDown', 16), on: { click: step(1) } }), + ), + ), + grid, + ); +} + +// ----------------------------------------------------------- quick settings + +function renderQuickSettings(): void { + const tiles = h('div', { class: 'tiles' }); + + const tile = (options: { + on: boolean; + glyph: string; + label: string; + sub?: string; + onClick: () => void; + disabled?: boolean; + }): HTMLElement => + h('button', { + class: `tile${options.on ? ' on' : ''}`, + disabled: options.disabled, + on: { click: options.onClick }, + }, + h('span', { html: options.glyph }), + h('span', { class: 'tile-label' }, + options.label, + options.sub ? h('div', { class: 'tile-sub' }, options.sub) : null, + )); + + const network = state?.network; + if (network?.wifiHardware) { + const active = network.active.find((c) => c.type.includes('wireless')); + tiles.append(tile({ + on: network.wifiEnabled, + // A real signal strength only exists per access point, and the tile + // has no room for one - so it says on/off rather than inventing bars. + glyph: icon(network.wifiEnabled ? 'wifi' : 'wifiOff', 20), + label: 'Wi-Fi', + sub: network.wifiEnabled ? (active?.name ?? 'Not connected') : 'Off', + onClick: () => post({ type: 'wifi', enabled: !network.wifiEnabled }), + })); + } + + if (state?.bluetooth?.available) { + const connected = state.bluetooth.devices.find((d) => d.connected); + tiles.append(tile({ + on: state.bluetooth.powered, + glyph: icon('bluetooth', 20), + label: 'Bluetooth', + sub: state.bluetooth.powered ? (connected?.name ?? 'Not connected') : 'Off', + onClick: () => post({ type: 'bluetooth', enabled: !state?.bluetooth?.powered }), + })); + } + + tiles.append(tile({ + on: state?.airplaneMode ?? false, + glyph: icon('airplane', 20), + label: 'Airplane mode', + onClick: () => post({ type: 'airplane', enabled: !state?.airplaneMode }), + })); + + tiles.append(tile({ + on: state?.energySaver ?? false, + glyph: icon('battery', 20), + label: 'Energy saver', + onClick: () => post({ type: 'energySaver', enabled: !state?.energySaver }), + })); + + tiles.append(tile({ + on: state?.nightLight ?? false, + glyph: icon('moon', 20), + label: 'Night light', + onClick: () => post({ type: 'nightLight', enabled: !state?.nightLight }), + })); + + tiles.append(tile({ + on: false, + glyph: icon('accessibility', 20), + label: 'Accessibility', + onClick: () => post({ type: 'accessibility' }), + })); + + card.append(title('Quick settings'), tiles); + + if (state?.brightness !== undefined) { + card.append(slider('sun', state.brightness, 1, 100, (value) => post({ type: 'brightness', value }))); + } + + if (state?.audio?.available) { + const audio = state.audio; + card.append(slider( + audio.muted ? 'volumeMute' : 'volumeHigh', + audio.muted ? 0 : audio.volume, + 0, + 100, + (value) => post({ type: 'volume', value }), + () => post({ type: 'mute' }), + )); + } + + const battery = state?.battery; + if (battery) { + card.append(h('p', { class: 'flyout-note' }, + battery.present + ? `Battery ${battery.level}% · ${battery.charging ? 'charging' : battery.onAc ? 'plugged in' : 'on battery'}` + : 'Running on mains power')); + } +} + +function slider( + glyph: string, + value: number, + min: number, + max: number, + onInput: (value: number) => void, + onIconClick?: () => void, +): HTMLElement { + const readout = h('span', { class: 'slider-value' }, `${Math.round(value)}%`); + const send = throttle(onInput, 120); + return h('div', { class: 'slider-row' }, + h(onIconClick ? 'button' : 'span', { + class: onIconClick ? 'icon-button' : '', + html: icon(glyph, 18), + on: onIconClick ? { click: onIconClick } : {}, + }), + h('input', { + type: 'range', + min, + max, + value, + on: { + input: (event: Event) => { + const next = Number((event.target as HTMLInputElement).value); + readout.textContent = `${next}%`; + send(next); + }, + }, + }), + readout, + ); +} + +// ------------------------------------------------------------------ volume + +function renderVolume(): void { + const audio = state?.audio; + if (!audio?.available) { + card.append(title('Volume'), h('div', { class: 'empty' }, 'No audio device found.')); + return; + } + + card.append( + title('Volume'), + slider( + audio.muted ? 'volumeMute' : 'volumeHigh', + audio.muted ? 0 : audio.volume, + 0, + 100, + (value) => post({ type: 'volume', value }), + () => post({ type: 'mute' }), + ), + h('div', { class: 'section-head' }, 'Output device'), + ); + + const list = h('div', { class: 'list' }); + if (audio.sinks.length === 0) { + list.append(h('div', { class: 'empty' }, 'No outputs reported.')); + } + for (const sink of audio.sinks) { + list.append(h('button', { + class: `list-row${sink.isDefault ? ' active' : ''}`, + on: { click: () => post({ type: 'sink', id: sink.id }) }, + }, + h('span', { html: icon('volume', 18) }), + h('span', { class: 'list-main' }, h('div', { class: 'list-name' }, sink.name)), + sink.isDefault ? h('span', { html: icon('check', 16) }) : null, + )); + } + card.append(list); +} + +// ----------------------------------------------------------------- network + +function renderNetwork(): void { + const network = state?.network; + card.append(title('Network')); + + if (!network?.available) { + card.append(h('div', { class: 'empty' }, 'NetworkManager is not running.')); + return; + } + + const list = h('div', { class: 'list' }); + + for (const connection of network.active.filter((c) => !c.type.includes('wireless'))) { + list.append(h('div', { class: 'list-row active' }, + h('span', { html: icon('ethernet', 18) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, connection.name), + h('div', { class: 'list-sub' }, `${connection.type} · ${connection.device}`), + ), + h('button', { + class: 'icon-button', + title: 'Disconnect', + html: icon('close', 15), + on: { click: () => post({ type: 'disconnect', name: connection.name }) }, + }), + )); + } + + card.append( + h('div', { class: 'section-head' }, + h('span', {}, network.wifiHardware ? 'Wi-Fi' : 'Connections'), + h('span', { style: { display: 'flex', gap: '2px' } }, + network.wifiHardware + ? h('button', { + class: 'icon-button', + title: network.wifiEnabled ? 'Turn Wi-Fi off' : 'Turn Wi-Fi on', + html: icon(network.wifiEnabled ? 'wifi' : 'wifiOff', 15), + on: { click: () => post({ type: 'wifi', enabled: !network.wifiEnabled }) }, + }) + : null, + network.wifiHardware && network.wifiEnabled + ? h('button', { + class: 'icon-button', + title: 'Scan again', + html: icon('refresh', 15), + on: { click: () => post({ type: 'scan' }) }, + }) + : null, + ), + ), + list, + ); + + if (busy) { + card.append(h('div', { class: 'empty' }, busy)); + return; + } + + if (!network.wifiHardware) { + card.append(h('p', { class: 'flyout-note' }, 'No wireless adapter on this machine.')); + return; + } + if (!network.wifiEnabled) { + card.append(h('div', { class: 'empty' }, 'Wi-Fi is off.')); + return; + } + if (network.accessPoints.length === 0) { + card.append(h('div', { class: 'empty' }, 'No networks in range.')); + return; + } + + const wifiList = h('div', { class: 'list' }); + for (const point of network.accessPoints) { + const secured = point.security !== '' && point.security !== '--'; + wifiList.append(h('button', { + class: `list-row${point.inUse ? ' active' : ''}`, + on: { + click: () => { + if (point.inUse) { + post({ type: 'disconnect', name: point.ssid }); + } else { + post({ type: 'connect', ssid: point.ssid, secured, known: point.known }); + } + }, + }, + }, + h('span', { html: signalIcon(point.signal, 18) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, point.ssid), + h('div', { class: 'list-sub' }, + point.inUse ? 'Connected' : point.known ? 'Saved' : secured ? point.security : 'Open'), + ), + secured ? h('span', { html: icon('lock', 14), style: { opacity: '0.7' } }) : null, + )); + } + card.append(wifiList); +} + +// ------------------------------------------------------------------- music + +function renderMusic(): void { + card.append(title('Music')); + + const launchers = h('div', { class: 'tiles', style: { gridTemplateColumns: 'repeat(2, 1fr)' } }, + h('button', { + class: 'tile', + on: { click: () => post({ type: 'launchMusic', service: 'spotify' }) }, + }, h('span', { html: icon('music', 20) }), h('span', { class: 'tile-label' }, 'Spotify Web')), + h('button', { + class: 'tile', + on: { click: () => post({ type: 'launchMusic', service: 'ytmusic' }) }, + }, h('span', { html: icon('play', 20) }), h('span', { class: 'tile-label' }, 'YouTube Music')), + ); + card.append(launchers); + + if (!state?.mprisAvailable) { + card.append(h('p', { class: 'flyout-note' }, + 'Install playerctl to control playback from here: sudo pacman -S playerctl')); + return; + } + + const playing = state.nowPlaying; + card.append(h('div', { class: 'section-head' }, 'Now playing')); + + if (!playing) { + card.append(h('div', { class: 'empty' }, + 'Nothing is playing. Open one of the services above and press play.')); + return; + } + + card.append(h('div', { class: 'now-playing' }, + playing.artUrl + ? h('img', { class: 'album-art', src: playing.artUrl, alt: '' }) + : h('div', { class: 'album-art', style: { display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: icon('music', 26) }), + h('div', { class: 'list-main' }, + h('div', { class: 'list-name', style: { fontWeight: '600' } }, playing.title), + h('div', { class: 'list-sub' }, playing.artist || '—'), + h('div', { class: 'list-sub' }, playing.album || playing.player), + ), + )); + + card.append(h('div', { class: 'transport' }, + h('button', { + title: 'Previous', + html: icon('previous', 18), + on: { click: () => post({ type: 'transport', action: 'previous' }) }, + }), + h('button', { + class: 'primary', + title: playing.status === 'Playing' ? 'Pause' : 'Play', + html: icon(playing.status === 'Playing' ? 'pause' : 'play', 20), + on: { click: () => post({ type: 'transport', action: 'playPause' }) }, + }), + h('button', { + title: 'Next', + html: icon('next', 18), + on: { click: () => post({ type: 'transport', action: 'next' }) }, + }), + )); + + if (playing.length > 0) { + card.append(h('div', { class: 'progress-row' }, + h('span', {}, formatDuration(playing.position)), + h('input', { + type: 'range', + min: 0, + max: Math.round(playing.length), + value: Math.round(playing.position), + on: { + change: (event: Event) => + post({ type: 'seek', seconds: Number((event.target as HTMLInputElement).value) }), + }, + }), + h('span', {}, formatDuration(playing.length)), + )); + } + + if ((state.players?.length ?? 0) > 1) { + card.append(h('p', { class: 'flyout-note' }, `Players: ${state.players?.join(', ')}`)); + } +} + +// Escape closes the flyout, the way a real one does. +window.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + post({ type: 'command', command: 'workbench.action.closePanel' }); + } +}); diff --git a/extension/media/src/lib/calc.ts b/extension/media/src/lib/calc.ts new file mode 100644 index 0000000..e3e056a --- /dev/null +++ b/extension/media/src/lib/calc.ts @@ -0,0 +1,199 @@ +// Expression evaluation for the calculator. +// +// Shunting-yard to RPN, then a stack evaluation. Deliberately NOT eval() or +// new Function(): a webview that hands a text field to the JS engine is exactly +// what the page's CSP exists to prevent. In its own module so it can be tested +// without a DOM. + +type Token = { kind: 'number'; value: number } | { kind: 'op' | 'fn' | 'paren'; value: string }; + +const PRECEDENCE: Record = { '+': 1, '−': 1, '×': 2, '÷': 2, '%': 2, '^': 3 }; +const FUNCTIONS: Record number> = { + sin: (v) => Math.sin(v), + cos: (v) => Math.cos(v), + tan: (v) => Math.tan(v), + ln: (v) => Math.log(v), + log: (v) => Math.log10(v), + '√': (v) => Math.sqrt(v), +}; + +/** + * Negation is a unary operator, and the only way to get it right is to make it + * one. Rewriting "−x" as "0 − x" is the obvious shortcut and it is wrong the + * moment the sign follows anything: "5 − −3" becomes "5 − 0 − 3" = 2 rather + * than 8, and "2 × −3" becomes "2 × 0 − 3" = −3 rather than −6. + * + * It rides the operator stack as a function, which gives it the binding it + * needs, and is kept out of FUNCTIONS so that "neg" is not typeable as text. + */ +const NEGATE = 'neg'; + +/** Everything `evaluate` returns that is not a number. */ +const FAILURES = ['Error', 'Undefined', 'Overflow']; + +/** + * Whether a result is a failure rather than a value. Callers must not push a + * failure into the history or carry it forward as the next expression, and + * checking the message text at each call site is how that goes wrong. + */ +export function isError(result: string): boolean { + return FAILURES.includes(result); +} + +function applyFunction(name: string, value: number): number { + return name === NEGATE ? -value : FUNCTIONS[name](value); +} + +function tokenize(source: string): Token[] | undefined { + // The keypad shows a typographic minus; the keyboard and clipboard produce + // an ASCII hyphen. Normalising here is what lets a leading "-5" pick up the + // unary-sign handling below instead of falling through as a bare operator. + const input = source.replace(/-/g, '−'); + const tokens: Token[] = []; + let i = 0; + while (i < input.length) { + const char = input[i]; + if (char === ' ') { + i++; + } else if (/[\d.]/.test(char)) { + let number = ''; + while (i < input.length && /[\d.]/.test(input[i])) { + number += input[i++]; + } + const value = Number(number); + if (!Number.isFinite(value)) { + return undefined; + } + tokens.push({ kind: 'number', value }); + } else if (char === 'π') { + tokens.push({ kind: 'number', value: Math.PI }); + i++; + } else if (char === 'e') { + tokens.push({ kind: 'number', value: Math.E }); + i++; + } else if (char === '(' || char === ')') { + tokens.push({ kind: 'paren', value: char }); + i++; + } else if (char in PRECEDENCE) { + // A minus in prefix position - at the start, after another operator, + // or just inside a '(' - is a sign, not a subtraction. + const previous = tokens[tokens.length - 1]; + const prefix = !previous + || previous.kind === 'op' + || previous.kind === 'fn' + || (previous.kind === 'paren' && previous.value === '('); + tokens.push(char === '−' && prefix + ? { kind: 'fn', value: NEGATE } + : { kind: 'op', value: char }); + i++; + } else { + const name = Object.keys(FUNCTIONS).find((fn) => input.startsWith(fn, i)); + if (!name) { + return undefined; + } + tokens.push({ kind: 'fn', value: name }); + i += name.length; + } + } + return tokens; +} + +/** Shunting-yard to RPN, then a stack evaluation. No eval(), no Function(). */ +export function evaluate(input: string): string { + const tokens = tokenize(input); + if (!tokens || tokens.length === 0) { + return 'Error'; + } + + const output: Token[] = []; + const operators: Token[] = []; + + for (const token of tokens) { + if (token.kind === 'number') { + output.push(token); + } else if (token.kind === 'fn') { + operators.push(token); + } else if (token.kind === 'op') { + while (operators.length) { + const top = operators[operators.length - 1]; + const higher = top.kind === 'fn' + || (top.kind === 'op' && PRECEDENCE[top.value] >= PRECEDENCE[token.value] && token.value !== '^'); + if (top.kind === 'paren' || !higher) { + break; + } + output.push(operators.pop() as Token); + } + operators.push(token); + } else if (token.value === '(') { + operators.push(token); + } else { + let matched = false; + while (operators.length) { + const top = operators.pop() as Token; + if (top.kind === 'paren' && top.value === '(') { + matched = true; + break; + } + output.push(top); + } + if (!matched) { + return 'Error'; + } + const top = operators[operators.length - 1]; + if (top?.kind === 'fn') { + output.push(operators.pop() as Token); + } + } + } + while (operators.length) { + const top = operators.pop() as Token; + if (top.kind === 'paren') { + return 'Error'; + } + output.push(top); + } + + const stack: number[] = []; + for (const token of output) { + if (token.kind === 'number') { + stack.push(token.value); + } else if (token.kind === 'fn') { + const value = stack.pop(); + if (value === undefined) { + return 'Error'; + } + stack.push(applyFunction(token.value, value)); + } else { + const right = stack.pop(); + const left = stack.pop(); + if (right === undefined || left === undefined) { + return 'Error'; + } + switch (token.value) { + case '+': stack.push(left + right); break; + case '−': stack.push(left - right); break; + case '×': stack.push(left * right); break; + case '÷': stack.push(right === 0 ? NaN : left / right); break; + case '%': stack.push(right === 0 ? NaN : left % right); break; + case '^': stack.push(left ** right); break; + default: return 'Error'; + } + } + } + + const value = stack.pop(); + if (value === undefined || stack.length > 0) { + return 'Error'; + } + if (Number.isNaN(value)) { + // Dividing by zero, or the root or log of a negative. Not "undefined", + // which in an editor reads like a JavaScript value rather than a domain + // error. + return 'Undefined'; + } + if (!Number.isFinite(value)) { + return 'Overflow'; + } + // Kill the float noise 0.1+0.2 produces without truncating real precision. + return String(Number(value.toPrecision(12))); +} diff --git a/extension/media/src/lib/dom.ts b/extension/media/src/lib/dom.ts new file mode 100644 index 0000000..40a7c16 --- /dev/null +++ b/extension/media/src/lib/dom.ts @@ -0,0 +1,160 @@ +// Shared webview helpers. +// +// No framework: every one of these pages is small, and a bundled UI library +// would cost more than the whole extension does. `h()` is the entire abstraction. + +export interface VsCodeApi { + postMessage(message: unknown): void; + getState(): T | undefined; + setState(state: T): void; +} + +declare function acquireVsCodeApi(): VsCodeApi; + +export const vscode: VsCodeApi = acquireVsCodeApi(); + +export function post(message: unknown): void { + vscode.postMessage(message); +} + +export type Child = Node | string | null | undefined | false; + +export interface Attributes { + class?: string; + id?: string; + title?: string; + type?: string; + value?: string | number; + placeholder?: string; + min?: string | number; + max?: string | number; + step?: string | number; + src?: string; + href?: string; + alt?: string; + controls?: boolean; + disabled?: boolean; + checked?: boolean; + hidden?: boolean; + role?: string; + tabIndex?: number; + dataset?: Record; + style?: Partial; + html?: string; + on?: Partial void>>; + [key: `aria-${string}`]: string | undefined; +} + +export function h( + tag: K, + attributes: Attributes = {}, + ...children: Child[] +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + for (const [key, value] of Object.entries(attributes)) { + if (value === undefined || value === null || value === false) { + continue; + } + if (key === 'on') { + for (const [event, handler] of Object.entries(value as Record)) { + element.addEventListener(event, handler); + } + } else if (key === 'dataset') { + Object.assign(element.dataset, value); + } else if (key === 'style') { + Object.assign(element.style, value); + } else if (key === 'html') { + // Only ever used with icon markup this file owns, never with user text. + element.innerHTML = String(value); + } else if (key === 'class') { + element.className = String(value); + } else if (key === 'tabIndex') { + element.tabIndex = Number(value); + } else if (key === 'disabled' || key === 'checked' || key === 'hidden' || key === 'controls') { + (element as unknown as Record)[key] = Boolean(value); + } else if (key === 'value') { + (element as unknown as Record).value = String(value); + } else { + element.setAttribute(key, String(value)); + } + } + for (const child of children.flat()) { + if (child === null || child === undefined || child === false) { + continue; + } + element.append(typeof child === 'string' ? document.createTextNode(child) : child); + } + return element; +} + +export function clear(node: HTMLElement): HTMLElement { + node.replaceChildren(); + return node; +} + +/** Like `node.append`, but skips the nulls conditional children produce. */ +export function append(node: HTMLElement, ...children: Child[]): HTMLElement { + for (const child of children) { + if (child !== null && child !== undefined && child !== false) { + node.append(child); + } + } + return node; +} + +export function root(): HTMLElement { + const existing = document.getElementById('root'); + if (existing) { + return existing; + } + const created = h('div', { id: 'root' }); + document.body.append(created); + return created; +} + +export function onMessage(handler: (message: T) => void): void { + window.addEventListener('message', (event: MessageEvent) => handler(event.data)); +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const exponent = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const value = bytes / 1024 ** exponent; + return `${value >= 100 || exponent === 0 ? Math.round(value) : value.toFixed(1)} ${units[exponent]}`; +} + +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '0:00'; + } + const total = Math.floor(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}` + : `${minutes}:${String(secs).padStart(2, '0')}`; +} + +/** Coalesce rapid slider input into one message per frame. */ +export function throttle void>(fn: T, ms: number): T { + let last = 0; + let pending: ReturnType | undefined; + return ((...args: Parameters) => { + const now = Date.now(); + const wait = ms - (now - last); + if (wait <= 0) { + last = now; + fn(...(args as never[])); + } else if (!pending) { + pending = setTimeout(() => { + pending = undefined; + last = Date.now(); + fn(...(args as never[])); + }, wait); + } + }) as T; +} diff --git a/extension/media/src/lib/icons.ts b/extension/media/src/lib/icons.ts new file mode 100644 index 0000000..6b34815 --- /dev/null +++ b/extension/media/src/lib/icons.ts @@ -0,0 +1,103 @@ +// Inline SVG icons. +// +// Codicons are a webfont that VS Code does not expose to extension webviews, and +// shipping the font would add ~70 KiB to every image for a dozen glyphs. These +// are drawn on a 24x24 grid and inherit currentColor, so they theme themselves. + +const PATHS: Record = { + power: 'M12 3v10M7.8 6.3a7 7 0 1 0 8.4 0', + restart: 'M4 12a8 8 0 1 1 2.3 5.7M4 12V6M4 12h6', + sleep: 'M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z', + logout: 'M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h11', + wifi: 'M2.5 9a15 15 0 0 1 19 0M5.5 12.5a10 10 0 0 1 13 0M8.5 16a5.5 5.5 0 0 1 7 0M12 19.5h.01', + wifiOff: 'M2.5 9a15 15 0 0 1 6-3.6M15 5.6A15 15 0 0 1 21.5 9M8.5 16a5.5 5.5 0 0 1 7 0M12 19.5h.01M3 3l18 18', + bluetooth: 'M7 7l10 10-5 4V3l5 4L7 17', + airplane: 'M21 15l-9-4V5.5a1.5 1.5 0 0 0-3 0V11l-9 4v2l9-2.5V19l-2.5 1.5V22l4-1 4 1v-1.5L12 19v-4.5L21 17z', + battery: 'M3 8h14a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM21 11v2', + bolt: 'M13 2L4 14h7l-1 8 9-12h-7l1-8z', + sun: 'M12 6.5V4M12 20v-2.5M6.5 12H4M20 12h-2.5M7.8 7.8L6 6M18 18l-1.8-1.8M7.8 16.2L6 18M18 6l-1.8 1.8', + moon: 'M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z', + accessibility: 'M12 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM4 8.5l8 1.5 8-1.5M12 10v5M12 15l-3 6M12 15l3 6', + volume: 'M11 5L6.5 9H3v6h3.5L11 19V5z', + volumeMute: 'M11 5L6.5 9H3v6h3.5L11 19V5zM16 9.5l5 5M21 9.5l-5 5', + volumeLow: 'M11 5L6.5 9H3v6h3.5L11 19V5zM15.5 9.5a3.5 3.5 0 0 1 0 5', + volumeHigh: 'M11 5L6.5 9H3v6h3.5L11 19V5zM15.5 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10', + play: 'M7 4l12 8-12 8V4z', + pause: 'M8 4v16M16 4v16', + next: 'M6 4l10 8-10 8V4zM19 4v16', + previous: 'M18 4L8 12l10 8V4zM5 4v16', + chevronLeft: 'M15 5l-7 7 7 7', + chevronRight: 'M9 5l7 7-7 7', + chevronUp: 'M5 15l7-7 7 7', + chevronDown: 'M5 9l7 7 7-7', + close: 'M6 6l12 12M18 6L6 18', + check: 'M4 12.5l5 5L20 6.5', + lock: 'M6 11h12v9H6v-9zM8.5 11V7.5a3.5 3.5 0 1 1 7 0V11', + refresh: 'M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5', + folder: 'M3 6.5h6l2 2.5h10v10H3v-12.5z', + file: 'M6 3h8l4 4v14H6V3zM14 3v4h4', + home: 'M4 11l8-7 8 7M6 10v10h12V10', + disk: 'M3 7a9 4 0 1 0 18 0A9 4 0 1 0 3 7M3 7v10a9 4 0 0 0 18 0V7', + download: 'M12 3v12M7 11l5 5 5-5M4 20h16', + image: 'M3 5h18v14H3V5zM3 16l5-5 4 4 3-3 6 6', + music: 'M9 18V5l10-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM19 16a3 3 0 1 1-6 0 3 3 0 0 1 6 0z', + video: 'M3 6h13v12H3V6zM16 10l5-3v10l-5-3', + trash: 'M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13', + plus: 'M12 5v14M5 12h14', + grid: 'M4 4h7v7H4V4zM13 4h7v7h-7V4zM4 13h7v7H4v-7zM13 13h7v7h-7v-7z', + list: 'M4 6h16M4 12h16M4 18h16', + mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM6 11a6 6 0 0 0 12 0M12 17v4', + stop: 'M6 6h12v12H6z', + camera: 'M3 7h4l2-2h6l2 2h4v13H3V7zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', + save: 'M4 4h12l4 4v12H4V4zM8 4v6h8V4M8 20v-6h8v6', + open: 'M4 5h6l2 2h8v12H4V5z', + editor: 'M4 4h16v16H4V4zM4 9h16', + undo: 'M9 8H5V4M5.5 8.5A7.5 7.5 0 1 1 4 13', + redo: 'M15 8h4V4M18.5 8.5A7.5 7.5 0 1 0 20 13', + ethernet: 'M12 3v8M8 15H4v6h4v-6zM14 15h-4v6h4v-6zM20 15h-4v6h4v-6zM4 11h16', + globe: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z', + cpu: 'M7 7h10v10H7V7zM9 3v4M15 3v4M9 17v4M15 17v4M3 9h4M3 15h4M17 9h4M17 15h4', + memory: 'M3 7h18v10H3V7zM7 17v3M12 17v3M17 17v3M7 11v2M12 11v2M17 11v2', + search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM16 16l5 5', + warning: 'M12 3l9.5 17H2.5L12 3zM12 10v5M12 18h.01', +}; + +/** Solid glyphs, drawn with a fill instead of a stroke. */ +const FILLED = new Set(['play', 'stop', 'airplane', 'bolt', 'next', 'previous']); + +export function icon(name: keyof typeof PATHS | string, size = 18): string { + const path = PATHS[name] ?? PATHS.file; + const style = FILLED.has(name) + ? 'fill="currentColor" stroke="none"' + : 'fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"'; + return ``; +} + +/** Wi-Fi bars for a 0-100 signal strength. */ +export function signalIcon(signal: number, size = 18): string { + const bars = signal >= 75 ? 4 : signal >= 50 ? 3 : signal >= 25 ? 2 : 1; + const arcs = [ + { d: 'M12 19.5h.01', level: 1 }, + { d: 'M8.5 16a5.5 5.5 0 0 1 7 0', level: 2 }, + { d: 'M5.5 12.5a10 10 0 0 1 13 0', level: 3 }, + { d: 'M2.5 9a15 15 0 0 1 19 0', level: 4 }, + ]; + const paths = arcs + .map((arc) => ``) + .join(''); + return ``; +} + +/** Battery pictogram whose fill tracks the charge. */ +export function batteryIcon(level: number, charging: boolean, size = 18): string { + const width = Math.max(0, Math.min(13, Math.round((level / 100) * 13))); + const bolt = charging + ? '' + : ''; + return ``; +} diff --git a/extension/media/src/notepad.ts b/extension/media/src/notepad.ts new file mode 100644 index 0000000..6fe8270 --- /dev/null +++ b/extension/media/src/notepad.ts @@ -0,0 +1,80 @@ +// Notepad: a plain-text scratchpad that saves to a real file. +// +// Deliberately not a second editor - anything that wants syntax highlighting or +// a diff belongs in VS Code proper, and "Open in editor" is one click away. + +import { clear, h, onMessage, post, root } from './lib/dom'; +import { icon } from './lib/icons'; +import type { HostMessage } from '../../src/webview/protocol'; + +let path: string | undefined; +let dirty = false; + +const textarea = h('textarea', { + id: 'notepad-text', + placeholder: 'Start typing…', + on: { + input: () => { + dirty = true; + renderStatus(); + }, + }, +}); + +const status = h('div', { class: 'status' }); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + button('New', 'file', () => { + if (!dirty || confirm('Discard unsaved changes?')) { + post({ type: 'newNote' }); + } + }), + button('Open', 'open', () => post({ type: 'openNote' })), + button('Save', 'save', () => post({ type: 'saveNote', text: textarea.value, path }), true), + button('Save as', 'save', () => post({ type: 'saveNote', text: textarea.value, path, saveAs: true })), + h('span', { class: 'spacer' }), + button('Open in editor', 'editor', () => post({ type: 'noteToEditor', text: textarea.value })), + ), + h('div', { class: 'body', style: { display: 'flex' } }, textarea), + status, +)); + +function button(label: string, glyph: string, onClick: () => void, primary = false): HTMLElement { + return h('button', { class: `button${primary ? ' primary' : ''}`, on: { click: onClick } }, + h('span', { html: icon(glyph, 15) }), label); +} + +onMessage((message) => { + if (message.type !== 'note') { + return; + } + path = message.path; + textarea.value = message.text; + dirty = message.dirty; + renderStatus(); +}); + +function renderStatus(): void { + const text = textarea.value; + const words = text.trim() ? text.trim().split(/\s+/).length : 0; + clear(status).append( + h('span', {}, path ? `${path}${dirty ? ' •' : ''}` : dirty ? 'Unsaved note •' : 'Unsaved note'), + h('span', {}, `${text.length} characters`), + h('span', {}, `${words} words`), + h('span', {}, `${text ? text.split('\n').length : 0} lines`), + ); +} + +document.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + post({ type: 'saveNote', text: textarea.value, path, saveAs: event.shiftKey }); + } else if ((event.ctrlKey || event.metaKey) && event.key === 'o') { + event.preventDefault(); + post({ type: 'openNote' }); + } +}); + +renderStatus(); +post({ type: 'ready' }); diff --git a/extension/media/src/paint.ts b/extension/media/src/paint.ts new file mode 100644 index 0000000..a847d8c --- /dev/null +++ b/extension/media/src/paint.ts @@ -0,0 +1,364 @@ +// Paint: brush, eraser, shapes, fill, text, undo/redo, open and save PNG. + +import { clear, h, onMessage, post, root } from './lib/dom'; +import { icon } from './lib/icons'; +import type { HostMessage } from '../../src/webview/protocol'; + +type Tool = 'brush' | 'eraser' | 'line' | 'rect' | 'ellipse' | 'fill' | 'text' | 'picker'; + +const COLORS = [ + '#000000', '#7f7f7f', '#c3c3c3', '#ffffff', + '#ed1c24', '#ff7f27', '#fff200', '#22b14c', + '#00a2e8', '#3f48cc', '#a349a4', '#b97a57', + '#ffaec9', '#ffc90e', '#efe4b0', '#b5e61d', +]; + +const WIDTH = 1000; +const HEIGHT = 640; + +let tool: Tool = 'brush'; +let color = '#000000'; +let lineWidth = 4; +let drawing = false; +let startX = 0; +let startY = 0; +/** Canvas contents before the in-progress shape, so a drag can be previewed. */ +let snapshot: ImageData | undefined; + +const undoStack: string[] = []; +const redoStack: string[] = []; + +const canvas = h('canvas', { id: 'canvas' }) as HTMLCanvasElement; +canvas.width = WIDTH; +canvas.height = HEIGHT; +const context = canvas.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D; +context.fillStyle = '#ffffff'; +context.fillRect(0, 0, WIDTH, HEIGHT); +context.lineCap = 'round'; +context.lineJoin = 'round'; + +const toolsPane = h('div', { class: 'paint-tools' }); +const swatchesPane = h('div', { class: 'swatches' }); +const widthLabel = h('span', { class: 'slider-value' }, '4'); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + h('button', { + class: 'button', on: { click: () => post({ type: 'openImage' }) }, + }, h('span', { html: icon('open', 15) }), 'Open'), + h('button', { + class: 'button primary', + on: { click: () => post({ type: 'savePng', dataUrl: canvas.toDataURL('image/png') }) }, + }, h('span', { html: icon('save', 15) }), 'Save PNG'), + h('button', { class: 'button', on: { click: undo } }, h('span', { html: icon('undo', 15) }), 'Undo'), + h('button', { class: 'button', on: { click: redo } }, h('span', { html: icon('redo', 15) }), 'Redo'), + h('button', { + class: 'button', + on: { + click: () => { + if (confirm('Clear the canvas?')) { + pushUndo(); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, WIDTH, HEIGHT); + } + }, + }, + }, h('span', { html: icon('trash', 15) }), 'Clear'), + h('span', { class: 'spacer' }), + h('div', { class: 'field' }, + h('label', {}, 'Size'), + h('input', { + type: 'range', min: 1, max: 60, value: lineWidth, + style: { width: '110px' }, + on: { + input: (event: Event) => { + lineWidth = Number((event.target as HTMLInputElement).value); + widthLabel.textContent = String(lineWidth); + }, + }, + }), + widthLabel, + ), + swatchesPane, + h('input', { + type: 'color', value: color, + title: 'Custom colour', + on: { input: (event: Event) => setColor((event.target as HTMLInputElement).value) }, + }), + ), + h('div', { class: 'body' }, h('div', { class: 'paint-wrap' }, + toolsPane, + h('div', { class: 'canvas-area' }, canvas), + )), +)); + +const TOOLS: [Tool, string, string][] = [ + ['brush', 'editor', 'Brush'], + ['eraser', 'close', 'Eraser'], + ['line', 'chevronRight', 'Line'], + ['rect', 'stop', 'Rectangle'], + ['ellipse', 'globe', 'Ellipse'], + ['fill', 'disk', 'Fill'], + ['text', 'file', 'Text'], + ['picker', 'search', 'Pick colour'], +]; + +function renderTools(): void { + clear(toolsPane); + for (const [name, glyph, label] of TOOLS) { + toolsPane.append(h('button', { + class: `tool${tool === name ? ' active' : ''}`, + title: label, + html: icon(glyph, 18), + on: { click: () => { tool = name; renderTools(); } }, + })); + } +} + +function renderSwatches(): void { + clear(swatchesPane); + for (const value of COLORS) { + swatchesPane.append(h('button', { + class: `swatch${value === color ? ' active' : ''}`, + style: { background: value }, + title: value, + on: { click: () => setColor(value) }, + })); + } +} + +function setColor(value: string): void { + color = value; + renderSwatches(); +} + +// ------------------------------------------------------------------- undo + +function pushUndo(): void { + undoStack.push(canvas.toDataURL()); + if (undoStack.length > 25) { + undoStack.shift(); + } + redoStack.length = 0; +} + +function restore(dataUrl: string): void { + const image = new Image(); + image.onload = () => { + context.clearRect(0, 0, WIDTH, HEIGHT); + context.drawImage(image, 0, 0); + }; + image.src = dataUrl; +} + +function undo(): void { + const previous = undoStack.pop(); + if (!previous) { + return; + } + redoStack.push(canvas.toDataURL()); + restore(previous); +} + +function redo(): void { + const next = redoStack.pop(); + if (!next) { + return; + } + undoStack.push(canvas.toDataURL()); + restore(next); +} + +// ---------------------------------------------------------------- drawing + +/** Canvas is scaled to fit the viewport, so pointer coords need unscaling. */ +function pointOf(event: PointerEvent): { x: number; y: number } { + const rect = canvas.getBoundingClientRect(); + return { + x: ((event.clientX - rect.left) / rect.width) * WIDTH, + y: ((event.clientY - rect.top) / rect.height) * HEIGHT, + }; +} + +canvas.addEventListener('pointerdown', (event) => { + const { x, y } = pointOf(event); + canvas.setPointerCapture(event.pointerId); + + if (tool === 'picker') { + const data = context.getImageData(Math.floor(x), Math.floor(y), 1, 1).data; + setColor(`#${[data[0], data[1], data[2]].map((c) => c.toString(16).padStart(2, '0')).join('')}`); + return; + } + + if (tool === 'text') { + const text = prompt('Text to draw'); + if (text) { + pushUndo(); + context.fillStyle = color; + context.font = `${Math.max(12, lineWidth * 5)}px var(--vscode-font-family, sans-serif)`; + context.fillText(text, x, y); + } + return; + } + + if (tool === 'fill') { + pushUndo(); + floodFill(Math.floor(x), Math.floor(y), color); + return; + } + + pushUndo(); + drawing = true; + startX = x; + startY = y; + snapshot = context.getImageData(0, 0, WIDTH, HEIGHT); + + if (tool === 'brush' || tool === 'eraser') { + context.beginPath(); + context.moveTo(x, y); + } +}); + +canvas.addEventListener('pointermove', (event) => { + if (!drawing) { + return; + } + const { x, y } = pointOf(event); + context.lineWidth = lineWidth; + context.strokeStyle = tool === 'eraser' ? '#ffffff' : color; + + if (tool === 'brush' || tool === 'eraser') { + context.lineTo(x, y); + context.stroke(); + return; + } + + // Shape tools redraw from the snapshot so the preview follows the pointer. + if (snapshot) { + context.putImageData(snapshot, 0, 0); + } + context.beginPath(); + if (tool === 'line') { + context.moveTo(startX, startY); + context.lineTo(x, y); + } else if (tool === 'rect') { + context.rect(startX, startY, x - startX, y - startY); + } else { + context.ellipse( + (startX + x) / 2, (startY + y) / 2, + Math.abs(x - startX) / 2, Math.abs(y - startY) / 2, + 0, 0, Math.PI * 2, + ); + } + context.stroke(); +}); + +const finish = (): void => { + drawing = false; + snapshot = undefined; + context.closePath(); +}; +canvas.addEventListener('pointerup', finish); +canvas.addEventListener('pointercancel', finish); +canvas.addEventListener('pointerleave', () => { + if (drawing && (tool === 'brush' || tool === 'eraser')) { + finish(); + } +}); + +/** Scanline flood fill; the recursive version blows the stack on a full canvas. */ +function floodFill(x: number, y: number, fill: string): void { + const image = context.getImageData(0, 0, WIDTH, HEIGHT); + const data = image.data; + const target = offset(x, y); + const start = [data[target], data[target + 1], data[target + 2], data[target + 3]]; + + const parsed = fill.replace('#', ''); + const replacement = [ + parseInt(parsed.slice(0, 2), 16), + parseInt(parsed.slice(2, 4), 16), + parseInt(parsed.slice(4, 6), 16), + 255, + ]; + if (start.every((value, index) => value === replacement[index])) { + return; + } + + const matches = (index: number): boolean => + Math.abs(data[index] - start[0]) < 12 + && Math.abs(data[index + 1] - start[1]) < 12 + && Math.abs(data[index + 2] - start[2]) < 12 + && Math.abs(data[index + 3] - start[3]) < 12; + + const stack: [number, number][] = [[x, y]]; + while (stack.length) { + const [px, py] = stack.pop() as [number, number]; + if (py < 0 || py >= HEIGHT) { + continue; + } + let left = px; + while (left >= 0 && matches(offset(left, py))) { + left--; + } + left++; + let spanAbove = false; + let spanBelow = false; + for (let cx = left; cx < WIDTH && matches(offset(cx, py)); cx++) { + const index = offset(cx, py); + data[index] = replacement[0]; + data[index + 1] = replacement[1]; + data[index + 2] = replacement[2]; + data[index + 3] = replacement[3]; + + if (py > 0 && matches(offset(cx, py - 1)) !== spanAbove) { + spanAbove = !spanAbove; + if (spanAbove) { + stack.push([cx, py - 1]); + } + } + if (py < HEIGHT - 1 && matches(offset(cx, py + 1)) !== spanBelow) { + spanBelow = !spanBelow; + if (spanBelow) { + stack.push([cx, py + 1]); + } + } + } + } + context.putImageData(image, 0, 0); +} + +function offset(x: number, y: number): number { + return (y * WIDTH + x) * 4; +} + +onMessage((message) => { + if (message.type !== 'image') { + return; + } + const image = new Image(); + image.onload = () => { + pushUndo(); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, WIDTH, HEIGHT); + // Fit rather than stretch, so an opened photo keeps its aspect ratio. + const scale = Math.min(WIDTH / image.width, HEIGHT / image.height, 1); + const width = image.width * scale; + const height = image.height * scale; + context.drawImage(image, (WIDTH - width) / 2, (HEIGHT - height) / 2, width, height); + }; + image.src = message.uri; +}); + +document.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'z') { + event.preventDefault(); + event.shiftKey ? redo() : undo(); + } else if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + post({ type: 'savePng', dataUrl: canvas.toDataURL('image/png') }); + } +}); + +renderTools(); +renderSwatches(); +post({ type: 'ready' }); diff --git a/extension/media/src/recorder.ts b/extension/media/src/recorder.ts new file mode 100644 index 0000000..7ca47cf --- /dev/null +++ b/extension/media/src/recorder.ts @@ -0,0 +1,112 @@ +// Voice recorder. +// +// The record button talks to a pw-record subprocess in the extension host, +// because VS Code denies webviews the microphone permission outright. Playback +// of the finished file is ordinary