form controls

[github]

a terminal style form being filled in, field by field

ui 54×16 15 fps 5.8 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="form-controls" options='{"title":"preferences"}'></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { formControls } from "ascii.rest/pieces";

<Ascii piece={formControls} options={{"title":"preferences"}} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="form-controls", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="form-controls" options={{"title":"preferences"}} />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { formControls } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, formControls, {"title":"preferences"});

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

form-controls.ts

[js] [github]
/*
 * form-controls: a settings form drawn the way a terminal app draws one. A
 * focus marker tabs down through a text input, a select, radio buttons, a
 * checkbox, a switch and a slider, changes each, saves, then changes them back.
 */
import type { Frame, Meta } from "../types.ts";

export interface FormControlsOptions {
  [key: string]: unknown;
  title: string;
}

export const meta = {
  name: "form controls",
  category: "ui",
  note: "a terminal style form being filled in, field by field",
  cols: 54,
  rows: 16,
  fps: 15,
  options: { title: "preferences" },
} satisfies Meta<FormControlsOptions>;

const EMAILS = ["you@example.com", "team@example.com"];
const UNITS = ["metric", "imperial"];
const THEMES = ["light", "dark", "system"];
const VOLUME = [40, 75];
const LABEL = 4, FIELD = 14, WIDE = 32; // columns: labels, controls, input width inside its brackets
const RIGHT = FIELD + WIDE + 4; // one past the right edge every control lines up on
const TRACK = 30; // slider cells

interface State {
  focus: number;
  email: string;
  typed: number;
  unit: number;
  open: boolean;
  hi: number;
  theme: number;
  mentions: boolean;
  knob: number;
  volume: number;
  pressed: boolean;
  saved: boolean;
}

// The tour of the form as timed changes to its state. Two passes, the
// second undoing the first, so the loop closes on the starting state.
function script() {
  const ev: [number, (s: State) => void][] = [];
  let T = 0, start = 0;
  const at = (dt: number, fn: (s: State) => void) => ev.push([(T += dt), fn]);
  for (const b of [1, 0]) {
    const a = 1 - b;
    at(0.35, (s) => (s.focus = 0));
    at(0.3, (s) => ((s.email = ""), (s.typed = T)));
    for (let i = 1; i <= EMAILS[b].length; i++) {
      const text = EMAILS[b].slice(0, i), when = T + 0.07;
      at(0.07, (s) => ((s.email = text), (s.typed = when)));
    }
    if (b) start = T + 0.2;
    at(0.9, (s) => (s.focus = 1));
    at(0.3, (s) => ((s.open = true), (s.hi = a)));
    at(0.4, (s) => (s.hi = b));
    at(0.35, (s) => ((s.unit = b), (s.open = false)));
    at(0.45, (s) => (s.focus = 2));
    at(0.35, (s) => (s.theme = b ? 1 : 0));
    at(0.45, (s) => (s.focus = 3));
    at(0.35, (s) => (s.mentions = !!b));
    at(0.45, (s) => (s.focus = 4));
    for (let k = 1; k <= 4; k++) at(k === 1 ? 0.35 : 0.07, (s) => (s.knob = b ? k : 4 - k));
    at(0.45, (s) => (s.focus = 5));
    for (let k = 1; k <= 7; k++) {
      const v = VOLUME[a] + ((VOLUME[b] - VOLUME[a]) * k) / 7;
      at(k === 1 ? 0.35 : 0.07, (s) => (s.volume = v));
    }
    at(0.45, (s) => (s.focus = 6));
    at(0.4, (s) => (s.pressed = true));
    at(0.18, (s) => ((s.pressed = false), (s.saved = true)));
    at(1.3, (s) => (s.saved = false));
  }
  return { ev, total: T + 0.4, start };
}

export default function formControls({ title = "preferences" }: Partial<FormControlsOptions> = {}): Frame {
  const { cols, rows } = meta;
  const { ev, total, start } = script();
  const grid = Array.from({ length: rows }, () => new Array<string>(cols));
  const text = (r: number, c: number, s: string) => {
    for (let i = 0; i < s.length && c + i < cols - 1; i++) grid[r][c + i] = s[i];
  };

  return (t) => {
    const p = (t + start) % total;
    const s: State = { focus: 6, email: EMAILS[0], typed: -9, unit: 0, open: false, hi: 0, theme: 0, mentions: false, knob: 0, volume: VOLUME[0], pressed: false, saved: false };
    for (const [when, fn] of ev) {
      if (when > p) break;
      fn(s);
    }

    for (const row of grid) row.fill(" ");
    const name = ` ${title} `;
    for (let c = 1; c < cols - 1; c++) grid[0][c] = grid[rows - 1][c] = "─";
    for (let r = 1; r < rows - 1; r++) grid[r][0] = grid[r][cols - 1] = "│";
    grid[0][0] = "╭";
    grid[0][cols - 1] = "╮";
    grid[rows - 1][0] = "╰";
    grid[rows - 1][cols - 1] = "╯";
    text(0, 2, name);
    const keys = " tab next · space change · enter save ";
    text(rows - 1, cols - 2 - keys.length, keys);

    const fields = ["email", "units", "theme", "notify", "sync", "volume"];
    fields.forEach((f, i) => {
      text(2 + 2 * i, LABEL, f);
      if (s.focus === i) text(2 + 2 * i, LABEL - 2, ">");
    });

    // The caret holds while keys are coming, then blinks.
    const since = p - s.typed;
    const caret = s.focus === 0 && (since < 0.4 || Math.floor((since - 0.4) / 0.5) % 2 === 1) ? "█" : " ";
    text(2, FIELD, "[ " + (s.email + caret).padEnd(WIDE) + " ]");

    text(4, FIELD, "[ " + UNITS[s.unit].padEnd(WIDE - 1) + (s.open ? "^" : "v") + " ]");

    text(6, FIELD, THEMES.map((th, i) => (s.theme === i ? "(*) " : "( ) ") + th).join("   "));
    text(8, FIELD, "[x] replies   " + (s.mentions ? "[x]" : "[ ]") + " mentions");

    // A switch: the knob slides along a shaded track, filling it behind.
    let sw = "▕";
    for (let i = 0; i < 6; i++) sw += i < s.knob ? "▒" : i < s.knob + 2 ? "█" : "░";
    text(10, FIELD, sw + "▏ " + (s.knob === 4 ? "on" : "off"));

    const k = Math.round((s.volume / 100) * (TRACK - 1));
    let track = "";
    for (let i = 0; i < TRACK; i++) track += i < k ? "━" : i === k ? "█" : "─";
    text(12, FIELD, track + String(Math.round(s.volume)).padStart(5) + "%");

    // Buttons at the right, the last one pressed with a fill.
    const save = s.pressed ? "[▒▒save▒▒]" : "[  save  ]";
    const bc = RIGHT - 10;
    text(14, bc - 13, "[ cancel ]");
    text(14, bc, save);
    if (s.focus === 6) text(14, bc - 1, ">");
    if (s.saved) text(14, LABEL, "saved");

    // The select's list drops over the rows beneath it.
    if (s.open) {
      const w = RIGHT - FIELD;
      text(5, FIELD, "┌" + "─".repeat(w - 2) + "┐");
      UNITS.forEach((u, i) => text(6 + i, FIELD, "│" + ((s.hi === i ? " > " : "   ") + u).padEnd(w - 2) + "│"));
      text(8, FIELD, "└" + "─".repeat(w - 2) + "┘");
    }
    return grid.map((row) => row.join("")).join("\n");
  };
}