UI Screens: Architecture-Level Functions

Published: 2023-06-29
Edited: 2023-08-14

In previous posts the arcitecture of this application was related: a Model-View-Presenter atop a stack of monads:

A Model-View-Presenter triad (horizontally) atop a stack of monads (IO, ReaderT, StateT, and Pipes)

This setup felt clean-enough initially given the size of the application, but some of the implementaiton details belied its suitability as the top level of abstraction. The main issue, as providentially revealed, was that it was mixing different levels of architectural function into a single layer, muddying things.

In this post I'm going to go over what was wrong with this architecture and what the fix was.

Issues and a Potential Solution

The user-interface of this application currently consists of three different UI screens:

Each screen represents a particular stage of operation in the app -- prompting, loading, or interactivity -- with the processing associated with a stage being done by the Model in the background.

The Model and Presenter of the MVP triad were implemented as state-machines, with each machine consisting of states from all three of the different UI screens:

The distinct screens' states within the Presenter and Model of the architecture

The fact that both machines had states for each of the three screens is where the problem lay; it meant that:

All of this added to the messiness and complexity of the code for these modules.

A way to improve this situation, removing these detractive qualities, seemeed to be to split the states out by screen and create an MVP triad for each to separate their concerns:

New architecture with a Model-View-Presenter triad and monad-stack for each screen

At first I was uncertain of how sound a decision this might be architecturally, but then something presented itself that quelled my doubts.

Architecture-Level Functions

To transition between the UI screens in the shared-state-machine architecture meant transitioning from one screen's state to another screen's in both the Model and the Presenter. If I were to separate the screens out into different MVP triads it would mean that such direct state-transitions would no-longer be possible when changing screens since their states would be in different Models and Presenters.

Furthermore, the data passed from state to state as part of these transitions would have to go through the new intermediary layer of the MVP triads whenever the app changed screens; whatever mechanism for screen-transitions I came up with would have to facilitate this data transfer.

It was while mulling over this detail w.r.t. the Loading screen that I had a moment of discernment -- one of many divine insights received during this proceess.

The Loading screen represents the stage of the application concerned with reading and processing a WAV file, so its triad would have to be given the path of a file to load, which it would do in the background, and it would have to return the loaded file and associated data for use by the next screen when it was done.

If you squint and look at this description in the abstract, it describes a function! It takes an input, does something, and returns an output! In fact, the application stages represented by every screen could be thought of in terms of being functions:

With this insight I had my abstraction, along with the realization that the screen-transition logic belonged on its own architectural layer -- so far as the overarching application flow is concerned, displaying stuff to the user and accepting UI input in each screen are just implementation details; the high-level screen-to-screen flow is functional: after a function (screen) is run, the next function to be called depends on the particular output received from the previous one.

Amid other, ongoing refactoring, I moved each screen's state-logic into its own MVP triad and wrapped it as an effectful function, then setup some simple logic to perform the transitions between them:

mainLoop configFilePaths settingsFilePath files = do
  (config, messages) <- readConfigFiles configFilePaths
  graphics <- initializeGraphicsContext (getStyle config) programName
  let context = GlobalContext programName config graphics
  _ <- nextScreen context =<<
    if null files
    then idleScreen context messages
    else loadingScreen context messages (head files)
  destroyGraphicsContext graphics

nextScreen context = \case
  GotFile path -> do
    ret <- loadingScreen context [] path
    nextScreen context ret
  FileLoadErrored{} -> do
    ret <- idleScreen context []
    nextScreen context ret
  FileLoaded audio segments waveRep -> do
    ret <- editingScreen context [] audio segments waveRep
    nextScreen context ret
  Close ->
    return ()
          

Conclusion

This solution segregated the screen-transition logic from the state-machines, extracting it to its own level of abstraction, making it cleaner and clearer. The rest of the code is now more cohesive, with better separation of concerns -- a definite win. (I did have to sacrifice the ability to dynamically reload the style at will with this change, but it was a hacky feature implementation-wise and needed to be rethought-out, which I'm still working on; I'll take the cleaner code for sure.)

I'm not sure if this solution is an already-established pattern for application architecture/flow which I've just rediscovered or if it's something new, but I thought I'd write about it since I could see it being handy for others; I think it might work well in the realm of videogames or mobile apps to represent the different menu screens a user can go through -- the menu item they select would be the return value of the screen-function they're on.

Acknowledgements