7GUIs implementation in Elm

Hi! :wave:

Long post ahead! Sorry! :weight_lifting_man:‍♂

Intro

Before Christmas, I learnt about the 7GUIs UI programming benchmark, and it seemed quite interesting to try and do.

I looked around and the Elm implementation was quite old and incomplete, so I went on to work on it during the Christmas break for fun.

The benchmark consists of 7 distinct UIs that you have to build, where they give you a mockup, and a text specification of the behaviour. As you progress, the exercises increase in complexity and scope, the last one being the hardest.

Links:

Commentary

Before continuing with my comments over the tasks and my implementations, I can say I recommend the tasks as a way to exercise different parts of elm and the web platform, and I found it an interesting and rewarding exercise. There are many ways to implement the same tasks, specially the more complex ones, so I’m sure someone else would do it differently and come up with interesting insights.

Setup

Here are some of my choices for setting up the project, with some reasoning:

Build and development tooling

Mainly based on a Makefile.

I wanted to avoid npm scripts, and JS bundler npm hell as much as possible, so all the build rules are in the Makefile instead. Nothing fancy. My disk appreciates it. For development, I used entr as the file watcher with the watch Makefile rule.

  • Kind of unrelated but I’ve taken some time to get to learn better make (pdf book) after so many years using it just copy pasting and I have to say it is very useful

I did add a package.json to get the uglify-js minifier which is invoked in the Makefile. I did learn by reading this forum about elm-minify and I will be migrating my projects to use it rather than manually configuring uglify. I think encapsulating the best minification practices from the community in that tool is a great idea.

Folder structure

  • src/
    • Common modules (Ui.elm HTML helpers, and the main page index.html and index.css)
    • tasks/{task-name}/
      • Home for each tasks code. Make will look for a Main.elm inside each folder to compile code to public/
  • public/
    • Compiled and deployable assets. What gets deployed to github pages.

Tasks commentary

If you need to understand a task, look at the specifications.

1. Counter (source)

Welp, not much to comment here. We’ve seen counters to death so many times.

I can say that the task itself requires the counter number to be displayed on an input box, I guess because the tasks were quite Java/Swing oriented initially. In any case, I complied, using readonly True so that it wasn’t modifiable.

A thing I used here that I picked up from some of @unsoundscapes’s examples is this kind of short hand main setup:

main =
    Browser.sandbox
        { init = 0
        , update = \_ count -> count + 1
        , view = view
        }

Very concise and nice if you ask me.

2. Temperature converter (source)

The classic, the bane of remote cross-continental teams chit-chat, celsius <-> farenheit converter :smile:

I didn’t over-engineer and kept the temperatures as String, but if this were a bigger application you probably would want them to be custom types.

As something interesting, the requirement that when the input for a temperature stops being valid, the other input box won’t update or blank, but keep the last valid conversion value. I accomplished this by forking the logic to update with the valid conversion rules, or just as plain strings depending on the float parsing:

            case String.toFloat value of
                Just num ->
                    updateTemperature temperatureUnit num model


                Nothing ->
                    updateAsString temperatureUnit value model

3. Flight Booker (sources)

Things start getting a bit more interesting here, in terms of requirements. Multi-page widget with form parsing, enabling/disabling parts and validation rules.

I did it mostly in one file, but ended up extracting a couple of data structures to make the code in Main.elm a bit nicer:

ValidatedDate.elm

Custom type that forces you to validate a date from string. Encapsulates the parsing from string, a bit of date logic, and date to string conversion.

I wasn’t ready for elm-parser yet, so went with elm-regex. And boy is it awkward… Specially if you want to use capture groups and more complex regexes…

FlightKind.elm

A very small enum data structure, a bit less noise for Main.elm.

Main.elm

I chose to represent the pages as a custom type, and kept the from info in the model as is, even though the one-way makes no sense with the return flight, we do need to keep the disabled value visible, so made more sense to keep both from and to dates at all times:

type Model
    = Form BookingForm
    | Booked BookingForm

type alias BookingForm =
    { kind : FlightKind
    , from : ValidatedDate
    , to : ValidatedDate
    }

Otherwise pretty normal Elm architecture stuff, with a few functions to help separate the views. I’m happy about how it turned out.

4. Timer (source)

This was generally a very weird task, and I had some problems understanding it. I guess it is there for testing crossing state lines and general cohesion and reactivity of all the UI, but it doesn’t make a lot of sense on its own.

It was quite straightforward to do, given the Elm architecture and how it is well set up for these kind of reactive tasks with subscriptions, update, and a view derived from the model.

I’d highlight on this one some HTML: The <input type="range"> and the <progress /> tags made it really easy to make this task work.

A small gotcha was formatting float numbers for the UI (given the IEEE 754 float weirdness), and couldn’t find an easy way to do so within Basics. In JS I would do this with toFixed. In the end I gave in and ended up using myrho/elm-round. It is the only non-elm/__ package I used on all the tasks.

5. CRUD (source)

This one I found quite straightforward as well, with a bit of thought around how to lay out the UI.

This is where I wrote the small Ui.elm, which is a few functions that use flexbox with some spacing for sort-of mini elm-ui. Just a few primitive functions, column, row, box, and a spacer. This kind of layout is easy and piece of cake for CSS grid, but I still don’t know that well enough to use it.

For the list of users I chose to use List ( Int, User ), Int being the id. Not the most correct or future proof but quite straight forward. I would definitely move this data structure out if this grew, along with the lastUserId, to abstract the List logic and the auto-incrementing id logic to ensure consistency and be able to change the implementation transparently.

6. Circle Drawer (source)

This was an interesting one. It is a canvas-like drawing thing with undo/redo and a modal window.

Welp, elm doesn’t have canvas right now, and I don’t know svg too well (and I’ve read bad things about the clicks and coordinates on svg), so I decided to go with plain divs and CSS.

The history stuff was fun to program and test, undo/redo is always like magic, and with immutable data it is quite easy.

The modal part, is one where the web platform doesn’t deliver. We do have confirm and alert modals, but anything else you have to code custom, maybe with window.open, or in-page. In my case I didn’t bother with draggable closable fake-windows, and didn’t want to mess with ports and a new elm app for a new window. So instead I just added a fixed container that shows when a circle is selected, below the canvas.

Another fun part was figuring out the mouse position on "canvas’ click for creating new circles. I knew how to do it semi-straightforward from a past side project, where I learnt about event.page{X,Y} and event.target.offset{Top,Left}, which can help if you don’t have fun nested scrollable containers on the page. You can see the decoders, which could help you depending on your situation.

Finally, a small weird thing. When you click and add a circle, and click it again to select it and show the modal at the bottom: The input range doesn’t really move when dragging it, but it does work and the events are triggered and values updated. I’m not really sure what I’m doing wrong here, given the other task had a slider that worked fine. I tried adding keyed on a test but didn’t fix it. I looked at the devtools but couldn’t see anything weird, so I have zero idea about why it gets stuck, and couldn’t reproduce standalone on an ellie. :man_shrugging:

7. Final boss: Cells (source)

This was the hardest task, and I believe it is by a huge margin, compared to the other ones. It took me 5 or 10 times longer than the previous task to complete, and probably a bit more to be happy about the state of the code.

Basically, it is a toy spreadsheet, but with the core functionality implemented. You can double-click to edit cells.

Beware if you want to try it out, the problem statement is lightly specified, and it links to an article that has the full spec mixed with a Scala implementation. Through implementation I had to go read the spec, and some times the Scala version to understand the details in the behavior.

I took one bite at a time, and worked in this order:

  1. Get the full UI rendering
    • Used absolutely positioned divs with strict height/width, just because. It has served me well.
  2. Add cell editing
    • Fought with content-editable and input events, cried, sweated :disappointed_relieved:, and finally ended up adding a full-size input on the cell and did normal editing.
  3. Added cell content parsing
    • Tried with regexes, cried a lot again. Decided to use elm-parser
    • Tried with elm-parser. Cried more. Started to get some results. Then found bugs and had to learn more about backtrackable/commit, couldn’t find anything useful. Cried more and added it randomly, and then got it working. To this day I’m still not sure if the parser is fully correct, but it seems to work fine
      • Writing parsers is fun, but I think we can do better explaining more than the basics. I want to do more elm-parser now.
  4. Added cell evaluation
    • This was very cool, it was at the point where formulas started working, but re-evaluating the whole spreadsheet on every change/render. I didn’t see any slowdown on my desktop, but felt dirty stopping here
  5. Added dependency tracking
    • Re-structured the parsing to extract dependencies and cache evaluation and refactored Cell
    • Added dependency tracking between cells
    • Added cell evaluation propagation to the dependents
  6. Cleanup, organize, refactor. Draw better boundaries between data structures. Html.Lazy.lazy6 :scream: because why not

Structure

Ended up with the following files:

  • Position.elm
    • Row/Column pair data structure, with some helpers and its parser
  • Cell/Parser.elm
    • Cell content parser and types
  • Cell.elm
    • Cell data structure with creation, manipulation, conversion and rendering functions
  • Matrix.elm
    • A bi-dimensional matrix that uses Position. Hidden in it is the storage as a 1-dimension array. Used to store all the cells
  • Dependencies.elm
    • Data structure to keep track of dependencies between Positions, and get the what depends on a Position
  • Main.elm
    • Connective tissue between the data structures, the DOM events, and the main update logic

Comments

This was a very interesting task, but way bigger than the previous ones. I ended up quite happy about the abstractions and data structures I ended up with, and there are a lot of interesting details (I think) on the implementation that I would love to post about and expand, but it would be too much for this post already.

I may make a few youtube videos or streams, or maybe try to apply to one of the conferences and see if I can talk about it.

Final thoughts

It was a very interesting exercise as a whole, and I learnt a bunch of things in the process. The exercises are not very well calibrated in terms of difficulty I feel, and the UIs are not the most usable or modern, but it is still useful.

I also feel like I am at a point where I’m comfortable with Elm and can play well to its strengths, which feels good. I enjoyed building, but also thinking about the data structures, finding the right (for me) boundaries when things hurt, and designing the code’s behavior.

But I also see a lot of things I don’t know about (parser combinators for example) or haven’t used in anger (elm-test, lazy, benchmarking, elm-ui) and I would like to do more of. I also haven’t used elm in any really big or production-level applications since 2 and a half years ago sadly.

Another insight I got from the task, is how much the web platform provides out of the box for building UIs and applications. It isn’t the most coherent or modern UI toolkit, but it is a real wonder, public and free. It would be terrible to see it become irrelevant in my opinion.

I hope the commentary has brought some value to you. I’m happy to chat and discuss about the tasks, explain any weird thing I’ve done in the implementations, and in general connect with anyone interested on this.

Have a nice day! :raised_hands:

31 Likes

Very nice writeup! I’d definitely encourage you to try out elm/parser, though, it’s a lot of fun to use and not too hard to get started.

Would using elm-units for temperature conversion be considered cheating? =)

5 Likes

I did use it for the spreadsheet project, for parsing the cell contents and positions. Once you get the hang of it, like you say, it is very fun and easy to get started, until you hit a dead alley or blank spot on your understanding of things. I got stuck once I had two parsers chomping on the same start (coordinate and range), and although I solved it, I’m not too confident. I think I’m going to ask in a new post about it to get feedback.

Because of elm-parser I’ve looked into parsimmon in JS, and other parser combinator libraries and articles, and it is very cool to learn. Is like learning a new superpower.

I don’t think it would be. If this was my production application I would do that without second thought. The exercise had the weird requirement that when an input being edited became an invalid number, the opposite input would keep its last valid added number.

If you think about it the temperature on both inputs is the same, as evidenced by elm-units, so modelling the form gets a bit weird given the requirement above. Maybe we could represent the temperature:

type alias Model =
    { temperature : Temperature, celsiusInput : String, farenheitInput : String }

But given the requirement from above I think we still need the input values to keep the possibly invalid strings for the view. Then temperature needs to be kept in sync with the last edited input if it was a valid number, and apply the changes appropriately to the input fields.

There is probably some way to model that form’s requirements explicitly with custom types, for example maybe something like:

type alias Model =
    { temperature : Temperature
    , celsiusInput : TemperatureInput
    , farenheitInput : TemperatureInput }

type TemperatureInput = Valid | Invalid String

Where an input can be either a valid, thus derived from the main temperature field, or if invalid, then it will have the invalid input string. On receiving an input message, when valid we update the canonical temperature, otherwise we don’t touch it and make the input Invalid. Seems like this could work well, although I may be missing something.

1 Like

This is an excellent project!

One minor note. There appears to be a bug in the Circle Drawing. Once you click on a circle the slider appears to change the circle size. If you drag the slider then the circle size changes but the slider position doesn’t change. Once you click somewhere else and then re-click on the circle the slider position is now correct. I’ve tested this on Windows with FF v64 and Chromium v71.

Thanks for checking it out @ianchanning. I did see the same thing and wrote about it. The curse of long posts I suppose :sweat_smile:

1 Like

The <input type="range"> gets its value from model.modal, which is only updated when a circle is clicked. I’m not exactly sure why model.modal needs to be in the model, why not directly set the selected circle’s radius as the range input’s value and get rid of model.modal? :wink:

Oh wow I totally missed that! I wasn’t updating the radius input value on RadiusInput :sweat: Thanks! Fixed at:

The modal needs to hide when there are not circles selected and show when they are. The modal open/closed is the Maybe and the radius inside is the input range value.

The selected circle’s radius is already updated on input, but I do have to keep some flag to mark the input as open/closed.

Also, if I don’t store the input value, when you click a different circle the range shows up with the previous radius edited, which is why I’m keeping the range value and keeping the input in sync.

Thanks a lot for having a look!

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.