Electron Adventures: Episode 96: Pywebview Terminal App

Now that we've done some hello worlds in Pywebview, let's try to build something more complicated - a terminal app.

As I mentioned previously, Pywebview lacks any sort of debugging tools on the frontend, so it would be a terrible idea to try writing any serious code in it. Fortunately we already have a working terminal app, and we just need to port it to Pywebview.

terminal.html

The document is nearly identical to what we had many times before:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="./terminal.css" />
  </head>
  <body>
    <h1>Very amazing terminal app</h1>
    <div id="terminal">
      <div id="history">
      </div>

      <div class="input-line">
        <span class="prompt">$</span>
        <form>
          <input type="text" autofocus />
        </form>
      </div>
    </div>
    <script src="./terminal.js"></script>
  </body>
</html>

terminal.css

As so is the styling:

body {
  background-color: #444;
  color: #fff;
}

h1 {
  font-family: monospace;
}

#terminal {
  font-family: monospace;
}

.input-line {
  display: flex;
}

.input-line > * {
  flex: 1;
}

.input-line > .prompt {
  flex: 0;
  padding-right: 0.5rem;
}

.output {
  padding-bottom: 0.5rem;
}

.input {
  color: #ffa;
}

.output {
  color: #afa;
  white-space: pre;
}

form {
  display: flex;
}

input {
  flex: 1;
  font-family: monospace;
  background-color: #444;
  color: #fff;
  border: none;
}

terminal.js

Only one thing is new:

let form = document.querySelector("form")
let input = document.querySelector("input")
let terminalHistory = document.querySelector("#history")

function createInputLine(command) {
  let inputLine = document.createElement("div")
  inputLine.className = "input-line"

  let promptSpan = document.createElement("span")
  promptSpan.className = "prompt"
  promptSpan.append("$")
  let inputSpan = document.createElement("span")
  inputSpan.className = "input"
  inputSpan.append(command)

  inputLine.append(promptSpan)
  inputLine.append(inputSpan)

  return inputLine
}

function createTerminalHistoryEntry(command, commandOutput) {
  let inputLine = createInputLine(command)
  let output = document.createElement("div")
  output.className = "output"
  output.append(commandOutput)
  terminalHistory.append(inputLine)
  terminalHistory.append(output)
}

form.addEventListener("submit", async (e) => {
  e.preventDefault()
  let command = input.value
  let output = await window.pywebview.api.execute(command)
  createTerminalHistoryEntry(command, output)
  input.value = ""
  input.scrollIntoView()
})

That thing being let output = await window.pywebview.api.execute(command). The execute(command) function needs to be exposed by the Python backend.

terminal

And finally the Python code:

#!/usr/bin/env python3

import webview
import subprocess

class App:
  def execute(self, command):
    result = subprocess.run(command, capture_output=True, shell=True, encoding="utf-8")
    return result.stdout + result.stderr

app = App()

window = webview.create_window(
  "Terminal App",
  "terminal.html",
  js_api=App()
)
webview.start()

We just expose a single method. We need to remember to convert it to string (with encoding="utf-8"), as pywebview can't send bytes over, even though technically that's a valid JavaScript types these days (Uint8Array).

Results

And here's the result:

electron-adventures-96-screenshot.png

Oh wait, what is this crap in the middle? As it turns out, our shitty OS specific webview decided to automatically turn "--" into a long dash, something nobody ever asked it to do. Neither Chrome nor Safari does that, nor any other program I've seen, it's just whichever crappy frontend Pywebview is using.

I already mentioned all the other problems with Pywebview, but this just shows again what a terrible idea it is to use whatever happens to be bundled with the OS. People often whine about Electron apps being big due to bundled browser, but that those few MBs avoid all such issues at once.

As usual, all the code for the episode is here.