DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 68: Malina Hex Editor

Time to do something more substantial in Malina - and the obvious thing is port our hex editor from episode 66.

In this episode we'll run into a lot of issues with Malina, but that's pretty much expected when dealing with a new framework.

@rollup/plugin-commonjs

Well, first we need to do some rollup config, my least favorite part of JavaScript.

$ npm i @rollup/plugin-commonjs
Enter fullscreen mode Exit fullscreen mode

And edit the rollup.config.js file to support commonjs():

import resolve from '@rollup/plugin-node-resolve';
import derver from 'derver/rollup-plugin';
import css from 'rollup-plugin-css-only';
import { terser } from "rollup-plugin-terser";
import malina from 'malinajs/malina-rollup'
import malinaSass from 'malinajs/plugins/sass'
import commonjs from '@rollup/plugin-commonjs';

const DEV = !!process.env.ROLLUP_WATCH;
const cssInJS = false;

export default {
  input: 'src/main.js',
  output: {
    file: 'public/bundle.js',
    format: 'iife',
  },
  plugins: [
    malina({
      hideLabel: !DEV,
      css: cssInJS,
      plugins: [malinaSass()]
    }),
    resolve(),
    commonjs(),
    !cssInJS && css({ output: 'bundle.css' }),
    DEV && derver(),
    !DEV && terser()
  ],
  watch: {
    clearScreen: false
  }
}
Enter fullscreen mode Exit fullscreen mode

There are multiple formats for npm packages, and bundlers need to be configured to support each particular format, and I really don't want to think about it, this should just work out of the box, but it doesn't.

Install dependencies

Now we can actually install dependencies. They wouldn't work without @rollup/plugin-commonjs.

$ npm i fast-printf buffer
Enter fullscreen mode Exit fullscreen mode

Now that this is out of the way let's get to the code.

src/StatusBar.xht

This file is completely identical to src/StatusBar.svelte from episode 66.

<script>
  import { printf } from "fast-printf"
  export let offset

  $: hexOffset = printf("%x", offset)
</script>

<div>
  Offset: {offset} ({hexOffset})
</div>

<style>
  div {
    margin-top: 8px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

src/AsciiSlice.xht

This file is also completely identical to src/AsciiSlice.svelte from episode 66. So far so good.

<script>
  export let data

  let ascii = ""
  for (let d of data) {
    if (d >= 32 && d <= 126) {
      ascii += String.fromCharCode(d)
    } else {
      ascii += "\xB7"
    }
  }
</script>

<span class="ascii">{ascii}</span>

<style>
  .ascii {
    white-space: pre;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

src/Slice.xht

In all the files we need to change .xht vs .svelte in imports, I won't be mentioning this any further.

There are however more differences from the Svelte version.

First, iterating some number of times. In Svelte if we want to iterate 16 times we can do {#each {length: 16} as _, i}. Malina does not support this, and we need to convert that to an array with {#each Array.from({length: 16}) as _, i}. To be honest both just need to add {#range ...} statement already, this is far too common use case. This has been an open Svelte issue for over two years, Svelte creator supports it, so I have no idea why it's still not happening.

The other difference is one of many bugs in Malina I discovered. We'd like to do {:else}&nbsp, but HTML entities do not work properly in Malina in if/else blocks.

I tried a workaround with JavaScript string with {:else}{"\xa0"} but that didn't work either, I'm guessing due to Malina agressively collapsing whitespace.

So for placeholder it's just some arbitrary character we'll give opacity: 0; to.

As a reminder, we need such placeholder rows to have same height as regular rows for our dynamic rendering logic to figure out which rows should be visible. Episode 66 has all the details.

<script>
  import { printf } from "fast-printf"
  import AsciiSlice from "./AsciiSlice.xht"

  export let offset
  export let data
  export let visible
</script>

<div class="row">
  {#if visible}
    <span class="offset">{printf("%06d", offset)}</span>
    <span class="hex">
      {#each Array.from({length: 16}) as _, i}
        <span data-offset={offset + i}>
          {data[i] !== undefined ? printf("%02x", data[i]) : " "}
        </span>
      {/each}
    </span>
    <AsciiSlice {data} />
  {:else}
    <span class="invisible">.</span>
  {/if}
</div>

<style>
  .invisible {
    opacity: 0;
  }
  .row:nth-child(even) {
    background-color: #555;
  }
  .offset {
    margin-right: 0.75em;
  }
  .hex span:nth-child(4n) {
    margin-right: 0.75em;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

src/MainView.xht

There's a lot of changes here:

<script>
  import Slice from "./Slice.xht"

  export let data

  let slices
  let main
  let firstVisible = 0
  let lastVisible = 200

  slices = []
  for (let i = 0; i < data.length; i += 16) {
    slices.push({
      offset: i,
      data: data.slice(i, i + 16),
    })
  }

  $: firstVisible, lastVisible, console.log("Visible:", firstVisible, lastVisible)

  function onmouseover(e) {
    if (!e.target.dataset.offset) {
      return
    }
    $emit("changeoffset", e.target.dataset.offset)
  }

  function setVisible() {
    let rowHeight = Math.max(10, main.scrollHeight / slices.length)
    firstVisible = Math.floor(main.scrollTop / rowHeight)
    lastVisible = Math.ceil((main.scrollTop + main.clientHeight) / rowHeight)
  }
</script>

<div
  class="main"
  on:mouseover={onmouseover}
  on:scroll={setVisible}
  #main
  use:setVisible
>
  {#each slices as slice, i}
    <Slice {...slice} visible={i >= firstVisible && i <= lastVisible} />
  {/each}
</div>

<malina:window on:resize={setVisible} />

<style>
  .main {
    flex: 1 1 auto;
    overflow-y: auto;
    width: 100%;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

First the good changes.

<svelte:window> became <malina:window>.

And #main is a shortcut for setting main to refer to that DOM node, something that would be use:{(node) => main = node} in Svelte. Longer version would work as well, but I like this shortcut.

Malina has simpler interface for creating custom events. Instead of tedious boilerplate:

import { createEventDispatcher } from "svelte"
let dispatch = createEventDispatcher()
dispatch("changeoffset", e.target.dataset.offset)
Enter fullscreen mode Exit fullscreen mode

You can just do this with $emit:

$emit("changeoffset", e.target.dataset.offset)
Enter fullscreen mode Exit fullscreen mode

I find that quite often Svelte code looks really clean for the usual use cases, but then doing anything slightly nonstandard turns it into import { ... } from "svelte" followed by a block of boilerplate. Malina covers a lot of such cases with special variables like $emit, $context, $element, $event, $onMount, $onDestroy etc. This saves a line or two of code each time, but it looks so much cleaner when there is less boilerplate, as boilerplate intermixed with main code really muddles the logic (boilerplate imports are less of a problem, as they stay on the side and you can just ignore them).

And now unfortunately the bad changes.

Unfortunately then we have a downside of Malina. Svelte supports arbitrary statements with $: { any code } and will re-run it reactively whenever any state variables referred in it changes.

Malina has much more limited support. It supports assignments. For single statements like console.log here you need to list its dependencies, which breaks DRY quite hard. For anything more complex you need to extract it into a function, and then list its dependencies as well. I'm not sure what motivated this change.

The code for setting slices from data was reactive in Svelte version. It's not reactive here. As right now data doesn't change after app is loaded, that's fine, but if we made it dynamic we'd need to extract it into a function, and call that function.

And we have one more problem. In Svelte use: actions happen once DOM has fully rendered. Malina will call it as soon as it created its DOM node, before children are rendered. And as far as I can tell, there's no way to ask Malina to notify us when rendering actually finished.

This is a problem, because we have to wait for children to render, otherwise we won't have main.scrollHeight, and so we won't be able to calculate rowHeight, and so none of the dynamic rendering logic will work.

I did a dirty workaround of setting rowHeight to minimum of 10 if we're called early, to prevent rendering the whole 1MB file. At least after it loads, the updates should be accurate.

src/Decodings.xht

Here's Decodings component:

<script>
  export let data
  export let offset

  let int8, uint8, int16, uint16, int32, uint32, int64, uint64, float32, float64

  $: bytesAvailable = data.length - offset
  $: data, offset, update()

  function update() {
    int8 = data.readInt8(offset)
    uint8 = data.readUInt8(offset)

    if (bytesAvailable >= 2) {
      int16 = data.readInt16LE(offset)
      uint16 = data.readUInt16LE(offset)
    } else {
      int16 = ""
      uint16 = ""
    }

    if (bytesAvailable >= 4) {
      int32 = data.readInt32LE(offset)
      uint32 = data.readUInt32LE(offset)
      float32 = data.readFloatLE(offset)
    } else {
      int32 = ""
      uint32 = ""
      float32 = ""
    }

    if (bytesAvailable >= 8) {
      int64 = data.readBigInt64LE(offset)
      uint64 = data.readBigUInt64LE(offset)
      float64 = data.readDoubleLE(offset)
    } else {
      int64 = ""
      uint64 = ""
      float64 = ""
    }
  }
</script>

<table>
  <tr><th>Type</th><th>Value</th></tr>
  <tr><td>Int8</td><td>{int8}</td></tr>
  <tr><td>UInt8</td><td>{uint8}</td></tr>
  <tr><td>Int16</td><td>{int16}</td></tr>
  <tr><td>UInt16</td><td>{uint16}</td></tr>
  <tr><td>Int32</td><td>{int32}</td></tr>
  <tr><td>UInt32</td><td>{uint32}</td></tr>
  <tr><td>Int64</td><td>{int64}</td></tr>
  <tr><td>UInt64</td><td>{uint64}</td></tr>
  <tr><td>Float32</td><td>{float32}</td></tr>
  <tr><td>Float64</td><td>{float64}</td></tr>
</table>

<style>
  table {
    margin-top: 8px;
  }
  th {
    text-align: left;
  }
  tr:nth-child(even) {
    background-color: #555;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

As previously mentioned, we can't have that update block as a reactive statement $: { ... }. We had to extract it to a function, then call that function with explicit dependencies as $: data, offset, update(). I'm not a fan of this change.

src/App.xht

And finally the App component.

<script>
  import { Buffer } from "buffer/"
  import MainView from "./MainView.xht"
  import Decodings from "./Decodings.xht"
  import StatusBar from "./StatusBar.xht"

  let data = Buffer.from(window.api.data)
  let offset = 0

  let t0 = performance.now()
  $tick(() => {
    let t1 = performance.now()
    console.log(`Loaded ${Math.round(data.length / 1024)}kB in ${t1 - t0}ms`)
  })
</script>

<div class="editor">
  <MainView {data} on:changeoffset={e => offset = e.detail}/>
  <Decodings {data} {offset} />
  <StatusBar {offset} />
</div>

<malina:head>
  <title>fancy-data.bin</title>
</malina:head>

<style>
  :global(body) {
    background-color: #222;
    color: #fff;
    font-family: monospace;
    padding: 0;
    margin: 0;
  }
  .editor {
    display: flex;
    flex-direction: column;
    height: 100vh;
    overflow: auto;
  }
  :global(.editor > *) {
    background-color: #444;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Trivially, svelte:head became malina:head and imports changed.

.editor > :global(*) CSS rule I wanted crashed Malina so I had to do a workaround.

More problematic is lack of anything comparable to Svelte await tick() function.

Malina has $tick(callback) which we helpfully don't have to import, and is less helpfully a callback instead of a promise. Unfortunately just like the problem we had before in the MainView, it is called as soon as parent component renders, before its children do, so this measurement is worthless now.

Performance

OK, we don't have hard numbers, but how well Malina performs compared with Svelte version, especially considering it was supposed to be higher performance than Svelte?

It is absolutely terrible.

Not only first render is slow - something that was true in Svelte as well before we added our optimizations. Scrolling around - something that was super fast even in unoptimized Svelte - takes forever in Malina. For 1MB scrolling a few lines takes 10s for the screen to update.

Obviously it would be possible to make this program much faster, but Svelte version is fast enough without any extra effort.

Should you use Malina?

No.

Between all the bugs, missing functionality, and awful performance, there's no reason to use Malina. Just use Svelte like everyone else, at least for the time being.

But I liked some of its ideas. Especially $emit, $context and friends were definitely positive over Svelte's boilerplate-heavy approach. I didn't have opportunity to use its other shortcuts, but if it cuts on boilerplate, I'm generally for it.

Results

Here's the results:

Episode 68 Screenshot

In the next episode, we'll go back to our Svelte version and teach it how to load files.

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

Discussion (0)