Building a hierarchy of GUI widget classes is pretty much a standard exercise in object-oriented programming. You have some sort of abstract Widget
class, with an a
The wxHaskell GUI library makes excellent use of phantom types to model a widget hierarchy.
The idea is the following: all widgets share the same implementation, namely they are foreign pointers to C++ objects. However, this doesn't mean that all widgets need to have the same type. Instead, we can build a hierarchy like this:
type Object a = ForeignPtr a
data CWindow a
data CControl a
data CButton a
type Window a = Object (CWindow a)
type Control a = Window (CControl a)
type Button a = Control (CButton a)
This way, a value of the type Control A
also matches the type Window b
, so you can use controls as windows, but not the other way round. As you can see, subtyping is implemented via a nested type parameter.
For more on this technique, see section 5 in Dan Leijen's paper on wxHaskell.
Note that this technique appears to be limited to the case where the actual representation of widgets is uniform, i.e. always the same. However, I am confident that with some thought, it can be extended to the case where widgets have different representations.
In particular, the observation is that object-orientation can be modeled by including the methods in the data type, like this
data CWindow a = CWindow
{ close :: IO ()
, ...
}
data CButton a = CButton
{ onClick :: (Mouse -> IO ()) -> IO ()
, ...
}
Subtyping may save some boilerplate here, but it's not required.
To understand what OOP, such as subtype polymorphism, can be done in Haskell you can look at OOHaskell. This reproduces the semantics of a variety of powerful OOP type systems, keeping most type inference. The actual data encoding was not being optimized, but I suspect type families might allow better presentations.
Modelling the interface hierarchy (e.g. Widget) can be done with type classes. Adding new instances is possible and so the set of concrete widgets is open. If you want a specific list of possible widgets then GADTs can be a succinct solution.
The special operation with subclasses is upcasting and downcasting.
This is first needed to have a collection of Widgets, and usual result is to use existential types. There are other interesting solutions if you read all the bits of the HList library. The upcasting is fairly easy and the compiler can be certain that all casts are valid at compilation time. The downcasting is inherently dynamic and requires some run-time type information support, usually Data.Typeable. Given something like Typeable the downcasting is just another type class, with the result wrapped in Maybe to indicate failure.
There is boilerplate associated with most of this, but QuasiQuoting and Templating can reduce this. The type inference can still largely work.
I have not explored the new Constraint kinds and types, but they may augment the existential solution to upcasting and downcasting.
Having actual widget objects is something that's very object-oriented. A commonly used technique in the functional world is to instead use Functional Reactive Programming (FRP). I'll briefly outline what a widget library in pure Haskell would look like when using FRP.
tl/dr: You don't handle "Widget objects", you handle collections of "event streams" instead, and don't care from which widgets or where those streams come from.
In FRP, there's the basic notion of an Event a
, which can be seen as an infinite list [(Time, a)]
. So, if you want to model a counter that counts up, you'd write it as [(00:01, 1), (00:02, 4), (00.03, 7), ...]
, which associates a specific counter value with a given time. If you want to model a button that is being pressed, you produce a [(00:01, ButtonPressed), (00:02, ButtonReleased), ...]
There's also commonly something called a Signal a
, which is like an Event a
, except that the modeled value is continuous. You don't have a discrete set of values at specific times, but you can ask the Signal
for its value at, say, 00:02:231
and it will give you the value 4.754
or something. Think of a signal as an analogue signal like the one on a heart charge meter (electrocardiographic device/Holter monitor) at a hospital: it's a continuous line that jumps up and down but never makes a "gap". A window does always have a title, for example (but perhaps it's the empty string), so you can always ask it for its value.
In a GUI library, on a low level, there'd be a mouseMovement :: Event (Int, Int)
and mouseAction :: Event (MouseButton, MouseAction)
or something. The mouseMovement
is the actual USB/PS2 mouse output, so you only get position differences as events (e.g. when the user moves the mouse up, you'd get the event (12:35:235, (0, -5))
. You'd then be able to "integrate" or rather "accumulate" the movement events to get a mousePosition :: Signal (Int, Int)
that gave you absolute mouse coordinates. mousePosition
could also take into consideration absolute pointing devices such as touch screens, or OS events that reposition the mouse cursor, etc.
Similarly for a keyboard, there'd be a keyboardAction :: Event (Key, Action)
, and one could also "integrate" that event stream into a keyboardState :: Signal (Key -> KeyState)
that lets you read a key's state at any point in time.
Things get more complicated when you want to draw stuff onto the screen and interact with widgets.
To create just a single window, one would have a "magic function" called:
window :: Event DrawCommand -> Signal WindowIcon -> Signal WindowTitle -> ...
-> FRP (Event (Int, Int) {- mouse events -},
Event (Key, Action) {- key events -},
...)
The function would be magical because it would have to call the OS-specific functions and create a window (unless the OS itself is FRP, but I doubt that). That is also why it is in the FRP
monad, because it would call createWindow
and setTitle
and registerKeyCallback
etc in the IO
monad behind the scenes.
One could of course group all of those values into data structures so that there would be:
window :: WindowProperties -> ReactiveWidget
-> FRP (ReactiveWindow, ReactiveWidget)
The WindowProperties
are signals and events that determine the look and behavior of the window (e.g. if there should be close buttons, what the title should be, etc.).
The ReactiveWidget
represents S&Es that are keyboard and mouse events, in case you want to emulate mouse clicks from within your application, and an Event DrawCommand
that represents a stream of things you want to draw on the window. This data structure is common to all widgets.
The ReactiveWindow
represents events like the window being minimized etc, and the output ReactiveWidget
represents mouse and keyboard events coming from the outside/the user.
Then one would create an actual widget, let's say a push button. It would have the signature:
button :: ButtonProperties -> ReactiveWidget -> (ReactiveButton, ReactiveWidget)
The ButtonProperties
would determine the color/text/etc of the button, and the ReactiveButton
would contain e.g. an Event ButtonAction
and Signal ButtonState
to read the button's state.
Note that the button
function is a pure function, since it only depends on pure FRP values like events and signals.
If one wants to group widgets (e.g. stack them horizontally), one would have to create e.g. a:
horizontalLayout :: HLayoutProperties -> ReactiveWidget
-> (ReactiveLayout, ReactiveWidget)
The HLayoutProperties
would contain information about border sizes and the ReactiveWidget
s for the contained widgets. The ReactiveLayout
would then contain a [ReactiveWidget]
with one element for each child widget.
What the layout would do is that it would have an internal Signal [Int]
that determined the height of each widget in the layout. It would then receive all of the events from the input ReactiveWidget
, then based upon the partition layout select an output ReactiveWidget
to send the event to, meanwhile also transforming the origin of e.g. mouse events by the partition offset.
To demonstrate how this API would work, consider this program:
main = runFRP $ do rec -- Recursive do, lets us use winInp lazily before it is defined
-- Create window:
(win, winOut) <- window winProps winInp
-- Create some arbitrary layout with our 2 widgets:
let (lay, layOut) = layout (def { widgets = [butOut, labOut] }) layInp
-- Create a button:
(but, butOut) = button butProps butInp
-- Create a label:
(lab, labOut) = label labProps labInp
-- Connect the layout input to the window output
layInp = winOut
-- Connect the layout output to the window input
winInp = layOut
-- Get the spliced input from the layout
[butInp, layInp] = layoutWidgets lay
-- "pure" is of course from Applicative Functors and indicates a constant Signal
winProps = def { title = pure "Hello, World!", size = pure (800, 600) }
butProps = def { title = pure "Click me!" }
labProps = def { text = reactiveIf
(buttonPressed but)
(pure "Button pressed") (pure "Button not pressed") }
return ()
(def
is from Data.Default
in data-default
)
This creates an event graph, like so:
Input events -> Input events ->
win ---------------------- lay ---------------------- but \
<- Draw commands etc. \ <- Draw commands etc. | | Button press ev.
\ Input events -> | V
\---------------------- lab /
<- Draw commands etc.
Note that there doesn't have to be any "widget objects" anywhere. A layout is simply a function that transforms input and output events according to a partitioning system, so you could use the event streams you gain access to for widgets, or you could let another sub-system generate the streams entirely. The same goes for buttons and labels: they are simply functions that convert click events into draw commands, or similar things. It's a representation of complete decoupling, and very flexible in its nature.