Craft By Zen

17 min read

#learning   #programming   #Javascript   #Svelte

7 GUIs

What is the 7 GUIs benchmark?

The 7 GUIs is a benchmark for comparing different GUI frameworks, proposed by Eugen Kiss. See his explanation below.

There are countless GUI toolkits in different languages and with diverse approaches to GUI development. Yet, diligent comparisons between them are rare. Whereas in a traditional benchmark competing implementations are compared in terms of their resource consumption, here implementations are compared in terms of their notation. To that end, 7GUIs defines seven tasks that represent typical challenges in GUI programming. In addition, 7GUIs provides a recommended set of evaluation dimensions.
Eugen Kiss

I’m going to walkthrough each GUI using Svelte, and annotate the code.

Counter

The task is to build a frame containing a label or read-only textfield T and a button B. Initially, the value in T is “0” and each click of B increases the value in T by one.

Link

Let’s walkthrough the code.

<script>
  // Initialize the counter with "0" (as it says in the spec)
  let count = 0;
</script>

<!-- Display the count, as a number input -->
<input
  type="number"
  bind:value={count}
/>
<!-- Add a button that will increment the counter by 1 with each click. -->
<button
  on:click={() => (count += 1)}>count</button
>

Temperature

The task is to build a frame containing two textfields TC and TF representing the temperature in Celsius and Fahrenheit, respectively. Initially, both TC and TF are empty. When the user enters a numerical value into TC the corresponding value in TF is automatically updated and vice versa. When the user enters a non-numerical string into TC the value in TF is not updated and vice versa. The formula for converting a temperature C in Celsius into a temperature F in Fahrenheit is C = (F - 32) * (5/9) and the dual direction is F = C * (9/5) + 32.

Link

°C = °F

Code walkthrough.

<script lang="ts">
  // Initialize the values of celsius and fahrenheit
  let c = 20;
  let f = 68;

  // Given the value from Celsius, update Fahrenheit
  function setBothFromC(value: number): void {
    // The + is to convert the string to a number
    c = +value;
    // Use the formula from the spec to update fahrenheit
    f = +(32 + (9 / 5) * c).toFixed(1);
  }

  // Given the value from Fahrenheit, update Celsius
  function setBothFromF(value: number): void {
    f = +value;
    // Use the formula from the spec to update celsius
    c = +((5 / 9) * (f - 32)).toFixed(1);
  }
</script>

<!--
Add two different inputs. Since the inputs are two-way bound by the values,
`c` and `f`, we can add an event listener to run the function to convert the
other value.
-->
<input
  value={c}
  on:input={(e) => setBothFromC(e.target.value)}
  type="number"
/>
°C =
<input
  value={f}
  on:input={(e) => setBothFromF(e.target.value)}
  type="number"
/>
°F

Flight Booker

The task is to build a frame containing a form with three textfields The task is to build a frame containing a combobox C with the two options “one-way flight” and “return flight”, two textfields T1 and T2 representing the start and return date, respectively, and a button B for submitting the selected flight. T2 is enabled iff C’s value is “return flight”. When C has the value “return flight” and T2’s date is strictly before T1’s then B is disabled. When a non-disabled textfield T has an ill-formatted date then T is colored red and B is disabled. When clicking B a message is displayed informing the user of his selection (e.g. “You have booked a one-way flight on 04.04.2014.”). Initially, C has the value “one-way flight” and T1 as well as T2 have the same (arbitrary) date (it is implied that T2 is disabled).

Link

<script>
  const DAY_IN_MS = 86400000
  const tomorrow = new Date(Date.now() + DAY_IN_MS);

  // Create an array of year, month, day, in this format: YYYY-MM-DD
  let start = [
    tomorrow.getFullYear(),
    pad(tomorrow.getMonth() + 1, 2),
    pad(tomorrow.getDate(), 2),
  ].join("-");

  // our reactive variables
  let end = start;
  let isReturn = false;

  // Running statements reactively, updating the variables when they are changed
  $: startDate = convertToDate(start);
  $: endDate = convertToDate(end);

  // Click handler for the button
  function bookFlight() {
    // Determine type of return
    const type = isReturn ? "return" : "one-way";

    let message = `You have booked a ${type} flight, leaving ${startDate.toDateString()}`;
    if (type === "return") {
      message += ` and returning ${endDate.toDateString()}`;
    }

    alert(message);
  }

  // Convert a string in the format YYYY-MM-DD to a Date object
  function convertToDate(str) {
    const split = str.split("-");
    return new Date(+split[0], +split[1] - 1, +split[2]);
  }

  // Pad a number with leading zeros
  function pad(x, len) {
    x = String(x);
    while (x.length < len) x = `0${x}`;
    return x;
  }
</script>

<!-- Create your select input for one-way or return flight option -->
<select bind:value={isReturn}>
  <option value={false}>one-way flight</option>
  <option value={true}>return flight</option>
</select>

<!-- Bind the inputs -->
<input type="date" bind:value={start} />
<input type="date" bind:value={end} disabled={!isReturn} />

<!-- Attempt to book flight -->
<button on:click={bookFlight} disabled={isReturn && startDate >= endDate}
  >book</button
>

Timer

The task is to build a frame containing a gauge G for the elapsed time e, a label which shows the elapsed time as a numerical value, a slider S by which the duration d of the timer can be adjusted while the timer is running and a reset button R. Adjusting S must immediately reflect on d and not only when S is released. It follows that while moving S the filled amount of G will (usually) change immediately. When e ≥ d is true then the timer stops (and G will be full). If, thereafter, d is increased such that d > e will be true then the timer restarts to tick until e ≥ d is true again. Clicking R will reset e to zero.

Link

<script>
  // It turns out, you can't run this in Astro without saying this component is client only
  import { onDestroy } from "svelte";

  // Start elapsed at 0 milliseconds
  let elapsed = 0;
  // Set the range input to be 5 seconds / 5000 milliseconds
  let duration = 5000;

  let last_time = window.performance.now();
  let frame;

  // IIFE for animation loop
  (function update() {
    // pass in the function for the animation frame (infinite looping)
    frame = requestAnimationFrame(update);

    // performance.now() is like Date.now(), but more accurate to tenths of a milliseconds
    const time = window.performance.now();

    // Take the minimum of the time elapsed and add it to the new elapsed time
    elapsed += Math.min(time - last_time, duration - elapsed);

    last_time = time;
  })();

  // When the component is destroyed, cancel the animation frame
  onDestroy(() => {
    cancelAnimationFrame(frame);
  });
</script>

<!-- Create the label and use the progress tag to show the time elapsed vs duration -->
<label>
  elapsed time:
  <progress value={elapsed / duration} />
</label>

<div>{(elapsed / 1000).toFixed(1)}s</div>

<label>
  duration:
  <!-- Bind the input to the duration. Max 20 seconds -->
  <input type="range" bind:value={duration} min="1" max="20000" />
</label>


<!-- Allow the user to reset the timer -->
<button on:click={() => (elapsed = 0)}>reset</button>

CRUD

The task is to build a frame containing the following elements: a textfield Tprefix, a pair of textfields Tname and Tsurname, a listbox L, buttons BC, BU and BD and the three labels as seen in the screenshot. L presents a view of the data in the database that consists of a list of names. At most one entry can be selected in L at a time. By entering a string into Tprefix the user can filter the names whose surname start with the entered prefix—this should happen immediately without having to submit the prefix with enter. Clicking BC will append the resulting name from concatenating the strings in Tname and Tsurname to L. BU and BD are enabled if an entry in L is selected. In contrast to BC, BU will not append the resulting name but instead replace the selected entry with the new name. BD will remove the selected entry. The layout is to be done like suggested in the screenshot. In particular, L must occupy all the remaining space.

<script>
  // Have some people to start with
  let people = [
    { first: "Hans", last: "Emil" },
    { first: "Max", last: "Mustermann" },
    { first: "Roman", last: "Tisch" },
  ];

  // Initialize the bound variables
  let prefix = "";
  let first = "";
  let last = "";
  // Initialize the selected item index
  let i = 0;

  // Reactive statements when the changes
  $: filteredPeople = prefix
    ? people.filter((person) => {
        const name = `${person.last}, ${person.first}`;

        // Filter based off first or last name
        return name.toLowerCase().startsWith(prefix.toLowerCase());
      })
    : people;

  // Reactively change the selected when filtered people
  $: selected = filteredPeople[i];

  // Reset all inputs when new selection made
  $: reset_inputs(selected);

  // Create a new person
  function create() {
    people = people.concat({ first, last });
    i = people.length - 1;
    first = last = "";
  }

  // Update the selected person
  function update() {
    selected.first = first;
    selected.last = last;
    people = people;
  }

  // Remove the selected person
  function remove() {
    // Remove selected person from the source array (people), not the filtered array
    const index = people.indexOf(selected);
    people = [...people.slice(0, index), ...people.slice(index + 1)];

    first = last = "";
    i = Math.min(i, filteredPeople.length - 2);
  }

  // Reset the input for first and last names
  function reset_inputs(person) {
    first = person ? person.first : "";
    last = person ? person.last : "";
  }
</script>

<input
  placeholder="filter prefix"
  bind:value={prefix}
/>

<select bind:value={i} size={5}>
  <!-- Loop through the filtered people -->
  {#each filteredPeople as person, i}
    <option value={i}>{person.last}, {person.first}</option>
  {/each}
</select>

<!-- Create inputs for first and last names -->
<label
  ><input
    bind:value={first}
    placeholder="first"
  /></label
>
<label
  ><input
    bind:value={last}
    placeholder="last"
  /></label
>

<!-- CRUD operators -->
<div class="buttons">
  <button on:click={create} disabled={!first || !last}>create</button>
  <button on:click={update} disabled={!first || !last || !selected}
    >update</button
  >
  <button on:click={remove} disabled={!selected}>delete</button>
</div>

Circle Drawer

The task is to build a frame containing an undo and redo button as well as a canvas area underneath. Left-clicking inside an empty area inside the canvas will create an unfilled circle with a fixed diameter whose center is the left-clicked point. The circle nearest to the mouse pointer such that the distance from its center to the pointer is less than its radius, if it exists, is filled with the color gray. The gray circle is the selected circle C. Right-clicking C will make a popup menu appear with one entry “Adjust diameter…”. Clicking on this entry will open another frame with a slider inside that adjusts the diameter of C. Changes are applied immediately. Closing this frame will mark the last diameter as significant for the undo/redo history. Clicking undo will undo the last significant change (i.e. circle creation or diameter adjustment). Clicking redo will reapply the last undoed change unless new changes were made by the user in the meantime.

Link

<script>
  // Initialize the bound variables
  let i = 0;
  let undoStack = [[]];
  let circles = [];
  let selected;
  let adjusting = false;
  let adjusted = false;

  // On handling click, create circle with default radius 50 px
  function handleClick(event) {
    if (adjusting) {
      adjusting = false;

      // if circle was adjusted,
      // push to the stack
      if (adjusted) push();
      return;
    }

    const circle = {
      cx: event.clientX,
      cy: event.clientY,
      r: 50,
    };

    // Add circles to list of circles. The selected circle is the current circle
    circles = circles.concat(circle);
    selected = circle;

    push();
  }

  function adjust(event) {
    selected.r = +event.target.value;
    circles = circles;
    adjusted = true;
  }

  function select(circle, event) {
    if (!adjusting) {
      event.stopPropagation();
      selected = circle;
    }
  }

  // Use a stack for keeping track of the circles
  function push() {
    const newUndoStack = undoStack.slice(0, ++i);
    newUndoStack.push(clone(circles));
    undoStack = newUndoStack;
  }

  function travel(d) {
    circles = clone(undoStack[(i += d)]);
    adjusting = false;
  }

  function clone(circles) {
    return circles.map(({ cx, cy, r }) => ({ cx, cy, r }));
  }
</script>

<!-- Put in the buttons for controls -->
<div class="controls">
  <button on:click={() => travel(-1)} disabled={i === 0}>undo</button>
  <button on:click={() => travel(+1)} disabled={i === undoStack.length - 1}
    >redo</button
  >
</div>

<!-- Draw with an SVG. Bind the click handler -->
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<svg on:click={handleClick}>
  <!-- Draw all of the circles -->
  {#each circles as circle}
    <!-- svelte-ignore a11y-click-events-have-key-events -->
    <circle
      cx={circle.cx}
      cy={circle.cy}
      r={circle.r}
      on:click={(event) => select(circle, event)}
      on:contextmenu|stopPropagation|preventDefault={() => {
        // When right-clicking, open the adjuster
        adjusting = !adjusting;
        if (adjusting) selected = circle;
      }}
      fill={circle === selected ? "#ccc" : "white"}
    />
  {/each}
</svg>

<!-- Show the adjuster if adjusting a circle's size -->
{#if adjusting}
  <div class="adjuster">
    <p>adjust diameter of circle at {selected.cx}, {selected.cy}</p>
    <input type="range" value={selected.r} on:input={adjust} />
  </div>
{/if}

Cells

The task is to create a simple but usable spreadsheet application. The spreadsheet should be scrollable. The rows should be numbered from 0 to 99 and the columns from A to Z. Double-clicking a cell C lets the user change C’s formula. After having finished editing the formula is parsed and evaluated and its updated value is shown in C. In addition, all cells which depend on C must be reevaluated. This process repeats until there are no more changes in the values of any cell (change propagation). Note that one should not just recompute the value of every cell but only of those cells that depend on another cell’s changed value. If there is an already provided spreadsheet widget it should not be used. Instead, another similar widget (like JTable in Swing) should be customized to become a reusable spreadsheet widget.

Link

This one isn’t in the Svelte documentation, so I found a different implementation that went through it perfectly. Link

Cells is split up into two Svelte components: Cell and Cells.

<!-- Cell.svelte -->
<script>
  // Initialized props
  export let j;
  export let i;
  export let focused;
  export let data;
  export let p;
  export let handleFocus;
  export let handleBlur;
  export let handleKeydown;
  export let handleInput;

  // Keep track of the current key
  let key = j + i;

  // Keep track if a cell is focused
  let hasFocus = false;
  $: if (focused === key && !hasFocus) {
    hasFocus = true;
  } else if (focused !== key && hasFocus) {
    hasFocus = false;
  }
</script>

<!-- When focused, change the cell into an input -->
<!-- Otherwise parse the formula -->
{#if hasFocus}
  <input
    id={"input-" + key}
    value={$data[key] || ""}
    autofocus
    on:focus={() => handleFocus(key)}
    on:blur={() => handleBlur(key)}
    on:keydown={(e) => handleKeydown(e, j, i)}
    on:input={(e) => handleInput(e, key)}
  />
{:else}
  <div>{p.parse($data[key]) || ""}</div>
{/if}
<!-- Cells.svelte -->
<script>
  import Cell from "./Cell.svelte";
  import { data } from "./store.js";
  import { sampleData } from "./sampleData.js";
  import { Parser } from "./parse.js";

  // Initialize with the sample data set
  data.set(sampleData);

  // Create 26 columns w/ the letters of the alphabet
  const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";

  // Max 100 x 100 cells
  export let shape = [100, 100];
  const rows = range(shape[1]);
  const columns = letterRange(shape[0]);
  const p = new Parser(data, columns, rows);
  let focused;
  let tBody;

  // Create range array
  function range(n) {
    return [...Array(n).keys()];
  }

  // Create letter range
  function letterRange(n) {
    return range(n).map(getNumberAsLetters);
  }

  // Loop through letters
  function getBase26(n) {
    let result = [];
    while (n > 25) {
      let remainder = n % 26;
      result.push(remainder);
      n = Math.floor(n / 26) - 1;
    }
    result.push(n);
    return result.reverse();
  }

  // Get the letter range and join them
  function getNumberAsLetters(n) {
    let arr = getBase26(n);
    return arr.map((num) => LETTERS[num]).join("");
  }

  function handleFocus(key) {
    if (focused !== key) {
      $data[key] = $data[key] || "";
      focused = key;
      setTimeout(() => {
        let target = tBody.querySelector("#input-" + key);
        if (target) {
          target.focus();
          target.setSelectionRange(0, 9999);
        }
      }, 10);
    }
  }

  function handleBlur(key) {
    if (focused === key) focused = undefined;
  }

  function handleInput(e, key) {
    $data[key] = e.target.value;
  }

  function handleKeydown(e, column, row) {
    // Navigate across the spreadsheet with arrow keys (and alt/option key)
    let selector;
    if (e.key === "ArrowUp") {
      let newRow = findAdjacent(rows, row, "before");
      selector = newRow !== null ? column + newRow : null;
    }
    if (e.key === "ArrowDown" || e.key === "Enter") {
      let newRow = findAdjacent(rows, row, "after");
      selector = newRow !== null ? column + newRow : null;
    }
    if (e.key === "ArrowLeft" && e.altKey) {
      let newColumn = findAdjacent(columns, column, "before");
      selector = newColumn !== null ? newColumn + row : null;
    }
    if (e.key === "ArrowRight" && e.altKey) {
      let newColumn = findAdjacent(columns, column, "after");
      selector = newColumn !== null ? newColumn + row : null;
    }

    if (selector) {
      e.preventDefault();
      handleFocus(selector);
    }
  }

  function findAdjacent(arr, value, direction) {
    let index = arr.indexOf(value);
    if (index === -1) return null;
    if (direction === "before")
      return arr[index - 1] === undefined ? null : arr[index - 1];
    if (direction === "after") return arr[index + 1] || null;
    return null;
  }

  function clear() {
    data.set({});
  }
</script>

<div class="wrapper">
  <table>
    <thead>
      <tr>
        <td class="row-key" />
        {#each columns as column}
          <td class="column-key">{column}</td>
        {/each}
      </tr>
    </thead>
    <tbody bind:this={tBody}>
      {#each rows as i}
        <tr id={"row-" + i}>
          <td class="row-key">{i}</td>
          {#each columns as j}
            <td id={j + i} on:click={() => handleFocus(j + i)}>
              <Cell
                {j}
                {i}
                {focused}
                {data}
                {p}
                {handleFocus}
                {handleBlur}
                {handleKeydown}
                {handleInput}
              />
            </td>
          {/each}
        </tr>
      {/each}
    </tbody>
  </table>
</div>
<button on:click={clear}>Clear</button>

There are two utility functions to help out the operations: parse and store (the latter being the Svelte store to save in local state).

// parse.js
export class Parser {
  constructor(store, columns, rows) {
    this.cells = {}
    this.store = store
    this.columns = columns
    this.rows = rows
    this.operations = {
      sum: (a, b) => a + b,
      sub: (a, b) => a - b,
      mul: (a, b) => a * b,
      div: (a, b) => a / b,
      mod: (a, b) => a % b,
      exp: (a, b) => a ** b
    }

    // subscribe to store
    this.store.subscribe(value => {
      this.cells = value
    })
  }

  cartesianProduct(letters, numbers) {
    var result = []
    letters.forEach(letter => {
      numbers.forEach(number => {
        result.push(letter + number)
      })
    })
    return result
  }

  findArrRange(arr, start, end) {
    let startI = arr.indexOf(start)
    let endI = arr.indexOf(end)
    if (startI == -1 || endI == -1 || startI > endI) return []
    return arr.slice(startI, endI + 1)
  }

  getRange(rangeStart, rangeEnd) {
    rangeStart = this.splitOperand(rangeStart)
    rangeEnd = this.splitOperand(rangeEnd)
    let letters = this.findArrRange(this.columns, rangeStart[0], rangeEnd[0])
    let numbers = this.findArrRange(this.rows, rangeStart[1], rangeEnd[1])
    return this.cartesianProduct(letters, numbers)
  }

  splitOperand(operand) {
    return [operand.match(/[a-zA-Z]+/)[0], Number(operand.match(/\d+/)[0])]
  }

  rangeOperation(op, rangeStart, rangeEnd) {
    if (!(this.isWellFormed(rangeStart) && this.isWellFormed(rangeEnd)))
      return this.originalString

    let range = this.getRange(rangeStart, rangeEnd)

    return range
      .map(address => Number(this.parse(this.cells[address])))
      .reduce(this.operations[op])
  }

  singleOperation(op, operand1, operand2) {
    let first = this.parseOperand(operand1)
    let second = this.parseOperand(operand2)

    if (first === null || second === null) return this.originalString

    return this.operations[op](first, second).toString()
  }

  isWellFormed(operand) {
    return /[a-zA-Z]+\d+/.test(operand)
  }

  parseOperand(operand) {
    if (!isNaN(Number(operand))) return Number(operand)
    if (operand in this.cells) return Number(this.parse(this.cells[operand]))
    if (this.isWellFormed(operand)) return 0

    return null
  }

  parseOperation(op, formula) {
    if (!(formula.startsWith('(') && formula.endsWith(')')))
      return this.originalString

    formula = formula.slice(1, formula.length - 1)

    let operationType
    let formulaArr
    if (formula.includes(',')) {
      operationType = 'single'
      formulaArr = formula.split(',')
    } else if (formula.includes(':')) {
      operationType = 'range'
      formulaArr = formula.split(':')
    }

    if (formulaArr.length !== 2) return this.originalString

    if (operationType === 'single')
      return this.singleOperation(op, formulaArr[0], formulaArr[1])

    if (operationType === 'range')
      return this.rangeOperation(op, formulaArr[0], formulaArr[1])

    return this.originalString
  }

  parse(str) {
    this.originalString = str
    if (typeof str !== 'string') return ''
    if (!str.startsWith('=')) return str

    let formula = str.slice(1)
    if (formula.slice(0, 3).toLowerCase() in this.operations) {
      return this.parseOperation(
        formula.slice(0, 3).toLowerCase(),
        formula.slice(3).toUpperCase()
      )
    } else {
      return this.cells[formula] || str
    }
  }
}
// store.js
import { writable } from "svelte/store";

export const data = writable({});

The last file is to load prefilled data, but we don’t need to go over that.

// An example of the sampleData file
export let sampleData = {
  A0: "Data",
  A1: "20",
  A2: "15"
};