NEWS, EDITORIALS, REFERENCE
Toolkit Class Hierarchy
A quick update on myself and situation. My schedule has become a bit tighter. In addition to working from home and watching my kids through the day, there has also been a recent medical crisis. A close member of my extended family was rushed to the hospital for an emergency heart surgery. She survived and is slowly recovering, but full recovery is going to take a long time. It's been stressful, especially for my wife and extended family. During this writing I also got some bad news about the sad passing of a friend and fellow software developer.
In memory of Christopher Roy. -2020
I'm plowing ahead with developing C64 OS. And I don't want to slow down the pace of putting out blog posts and updates, but I may not be able to put out 10,000+ word deep dives as frequently as I have before. I'll just play it by ear, and maybe put out some shorter posts for a while, until things settle down a bit.
This post is about the Toolkit class hierarchy. This is probably what the finalized v1.0 will release with. Although there is always some chance that it will change. In this post we'll also look at how some of the classes are structured and how they work. A previous post discusses how Object Orientation in 6502 (take 2) works, but doesn't discuss how the toolkit classes make use of those OO-techniques.
No time to waste, so let's just dig right in.
What I've discovered, after working out how to implement object-oriented classes on the 6502, is that OO programming is quite memory intensive. I don't want to say that it's bloated, because you gain a lot of power and flexibility but at a cost of memory usage. Classes definitely take up a lot more memory than if you simply hardcoded everything flat and static. I know this in particular because many of the first C64 OS Utilities that I've written, which have simple, mostly static UI's, are not using the toolkit. Static hardcoded UIs can look similar to a real Toolkit UI, but the similarity ends at how they look. Static UIs take more work to implement and their behavior is simpler, more crude and less consistent, but at the end of the day they're also very lightweight.
When you first start to look—for inspiration—at object-oriented toolkits on other platforms, it is super easy to get sucked down the rabbit hole. You have to remember that modern computers have not just megabytes of memory anymore, but gigabytes. Modern computers have a near infinite amount of memory in which to play around with vaste hierarchies of complex objects with deep inheritance trees.
Even on the Amiga, which typically had 512K or 1 or 2 megabytes of memory, Intuition didn't gain an object-oriented toolkit until 1990. And those machines have anywhere from 8, 16 or 32 times the amount of memory as a Commodore 64. So, inspiration is fun and useful, but it is absolutely critical that I whittle both the class hierarchy and its functionality down to the bare essentials.
The Class Hierarchy
Originally I'd had some hare-brained idea of having just 6 classes. But that was in my first post about Object Orientation in 6502, and everything about that post turned out to be stupid and wrong. I didn't know what I was doing or talking about when I wrote that.
Here's the new Toolkit class hierarchy:
Implements the essentials of being an object:
Fundamentally important superclass from which all others derive.
A multi-line text editing view.
May be packaged separately from the KERNAL
depending on how big it needs to be.
A container view for multiple child views.
A container view for multiple child views.
A container view for two child views.
A container view for one child and two scroll bars.
A single line text label.
Superclass from which all other controls derive.
Scroll bar, with arrows and a proportional nub.
Bar with adjustable nub.
Single line text input.
Button configurable as: push, pop-over, cycle, radio or check box.
Container view for multiple child tkctrls.
That's 14 classes. And the inheritance hierarchy is only 4 levels deep. Let's dig in a bit to some of these classes to get an idea for how they're structured, and that will help us to see why it is that deep inheritance hierarchies consume memory. And it will also become clear why it is important to plan carefully around the "assignment of duty", so to speak, to each class.
All objects are tkobjs
All objects ultimately inherit from tkobj. But this class does very little. Basically it just gives the object an isa pointer, to identify what class it is, and it implements the init and delete methods. Tkobj's implementation of delete doesn't do much. It deallocates the memory that was allocated when the object was created, and that's about it. Tkobj's implementation of init doesn't do anything at all, it's just a placeholder.
However, subclasses that need initialization will implement their own init method. And subclasses that need to clean up before being deleted will implement their own delete method. The subclass implementation of delete, say, only needs to deal with the custom clean up for that class, after which, it should call its superclass's implementation of delete. If every subclass follows that process, eventually the tkobj implementation of delete will be called, which handles freeing the memory for every type of subclass.
This means that every single class, which is by definition a descendent of tkobj, has to have jump table entries for init and delete. Even if a subclass, like tklabel, doesn't have any special init or delete behaviors, it has to have init and delete entries in its jump table.
If tklabel has no custom init or delete behavior, then its jump table entries should jump to the corresponding entries for its superclass, which is tkview. That way tklabel becomes agnostic about what tkview needs to do for init and delete. Maybe tkview's jump table entries point to its own methods, or maybe they do exactly what tklabel does and jump to the jump table entries of its own superclass.
The power is in the flexibility afforded by the abstraction. Perhaps in version 1 tklabel's superclass, tkview, doesn't have any custom init or delete work. But in version 2 tkview gains custom work for delete. The tklabel subclass doesn't need to change just because its superclass all of a sudden started needing to do some work. This is why it is important that a subclass always call its immediate superclass and not just call directly to the ancestor class that you know performs useful work. On a first release, that might be okay, but if an intermediate superclass started doing some init or delete work, tklabel's poor implementation would skip tkview and things would break horribly.
Version 1 at top. Version 2 at bottom. tklabel's implementation is not changed across versions.
But here's what's important to notice. If tkobj has two methods, and needs 3-bytes per method in its jump table, then every single descendent class needs at least 6 bytes, to handle those methods. We have 13 subclasses, so that's 13 * 6 or 78 bytes of memory that are used just for the init and delete jump table entries, even if they do absolutely nothing except idly jump from one class's jump table to the next until reaching the tkobj jump table, that frees the memory.
When this first becomes apparent, you realize, holy crap, init and delete on every single class better be worth it! Hopefully, presumably, the flexibility gained through the abstraction is in fact worth that use of memory. Let's return to this in minute.
All other objects are also views
There are some languages, and some programming environments, where the philosophy is that everything is an object. Java comes to mind. But also in NeXTStep/OpenStep and the macOS/iOS frameworks that derive from them, the most common classes are divided into two frameworks: appkit and foundation. Foundation is full of classes for low-level programming concepts, such as arrays, strings and numbers and others too that are slightly higher level like timers, exceptions, dates, and character sets. Appkit, on the other hand, is what implements all the classes for constructing a dynamic user interface.
As powerful and flexible as object-oriented programming is, the fact that it consumes memory so voraciously makes it impractical for ints and arrays, dates and exceptions to all be objects in C64 OS. In a sense then, the entire class hierarchy (with the sole exception of tkobj) are geared towards building dynamic user interfaces. That is why the classes in C64 OS are prefixed with tk for toolkit, because they are all part of the user interface toolkit kernal module.
Outside of toolkit, C64 OS isn't object oriented. For example, C64 OS has exception handling, but it is extraordinarily lightweight. C64 OS handles the exchange of dates, but a date is only a formatted c-string with an associated datatype of text/date, very very lightweight. Timers are simple property structures. Arrays, strings, ints and floats are similarly primitive.
First, what is a view?
Views come from the software design pattern of model/view/controller, or MVC for short. I've worked on teams with a variety of programmers throughout my career, and everyone I've ever had this discussion with has had a different idea about how model/view/controller is meant to be implemented. As a design philosophy, how it should be instantiated isn't very well defined. My understanding, which comes primarily from Apple's programming documentation and technical discussions, is as follows:
• A model holds the data, and has methods for accessing and manipulating that data.
• A view is a means of visually representing some facet of the data provided by the model, and accepting and interpreting user interaction.
• A controller is the bulk of the custom logic of an application that is used to mediate between the models and the views.
One of the most impressive displays of the utility of this design pattern is when multiple views visualize the same model but in different ways. When one view is interacted with to manipulate the underlying data, the controller ensures that all affected views are kept informed off the changes. You then see changes to the data reflected in realtime in neighboring views that you're not interacting with.
For example, you slide a slider, and both at the same time, you see a pie chart rerender and a table reorder to reflect the change. Everyone who sees this intuitively recognizes how cool computers are.
Here's a quick video that gives an overview of the style of MVC that C64 OS uses. It's a bit different than the MVC you may be used to if you've worked with various server/client web frameworks.
In C64 OS, though, neither the models nore the controllers are object-oriented. Only the views are. Tkview inherits from tkobj, and every other toolkit class inherits ultimately from tkview. Therefore, besides tkobj, every other class is a type of view.What does a view provide?
Since view's are by definition visual representations of data, all views have spacial and visual properties. This is not true of all programming objects generally, of course. If you are using a framework like Foundation, you have a class for an array. But, array's certainly don't have spacial and visual properties, they are a mechanism of data storage and retrieval. But views, all view classes, are spacial and visual.
Tkview is a doozy of a class. It implements a ton of behavior that is inherited by every other class. Those classes can rely heavily on tkview's implementation and therefore do not have to implement these behaviors themselves.
Tkview has, roughly speaking, five categories of behavior:
|Properties that establish the parent/child/sibling relationships between the views to describe their nested arrangement in a tree-like structure.
|Properties describe the width and height of a view, its anchoring relationship to its parent (containing) view, and also its own scroll offsets which change the origin of its children.
|Views know how to draw themselves into the draw context alloted to them. They also maintain properties about their visibility, their need to be redrawn, their opacity, and state of being in or out of focus. And they propagate this information to their children.
|Views need to notice or detect whether a low-level mouse event has actually hit them. This then gets converted into a responder event that gets propagated from the hit view through the hierarchy of responders.
|Some flags in dflags affect hittesting.
|Views respond to mouse and keyboard events. And if a specific view receives an event but doesn't respond to it directly, its default behavior is to propagate the event to the next view up the hierarchy of responders.
* In OpenStep's appkit, the responsibilities of a view are factored into two classes: NSView and NSResponder. NSView inherits from NSResponder, and NSResponder inherits directly from the root class NSObject. The reason for this factoring is because in addition to views, there are windows and even the application itself which are all also objects, and they inherit from NSResponder too. In C64 OS the application has a standard jump table for the system to send it messages, but an application is not an object. And C64 OS does not have windows. Therefore, in toolkit, the responder responsibilities are simply rolled into tkview.
Before proceeding, recall how tkobj has two methods. Every other class has to have jump table entries for these two methods. Well, now understand that every other class inherits from tkview, and therefore, every other class has to have jump table entries for all of tkview's methods. Tkview has 15 methods, and that's 3 bytes per method, or 45 bytes, times 14 classes, that's 630 bytes, (more than two full pages of memory, >1/128th of all memory) just for these method jump tables, without counting any of the actual implementations.
Additionally, tkview declares 33 bytes of properties. Every instance of any tkview or tkview subclass will take a minimum of 33 bytes, plus the additional size needed for the properties of the subclass. If you create and nest 10 tkviews together, that will consume at least 330 bytes. So, memory starts to add up. You don't want to have a ton of extraneous properties on tkview that might be only theoretically useful, or you start to bloat out every other instantiated class that inherits from tkview.
I'm pointing out the memory consumption issue, because you have to bear it in mind while considering the minimalism of the behavior of all of these toolkit classes.
A user interface is at its core a tree-like arrangement of views. Views within views, within more views. The layout of the views is dictated by the nesting arrangement via their node properties.
Every view has only a single parent, but can have multiple children. A parent node, though, has a pointer to only one child, its first child. Every node has a pointer to only one sibling, its next immediate sibling. Thus, if you have a parent view with 5 children, the way to access child number 5 is like this:
parent → first child → sibling → sibling → sibling → sibling
Every one of those siblings, however, has its parent pointer set back to the same parent view. The node hierarchy is used extensively by every other category of functionality. Every view subclass needs these properties, because every view plugs into the node tree by means of them.
There are only two methods, addchild and unparent. Add child is called on a parent view with a child view passed to it. The method performs the work of establishing the parent, child and sibling links. As each child is added, it becomes the next sibling in the chain of siblings. Unparent is called on a view, which removes it from its parent. If it's in the middle of a chain of siblings its two neighboring siblings get linked together. These methods allow you to programmatically rearrange the view hierarchy at runtime.
Sizing and Scrolling
In a previous post, context drawing system, I wrote in detail how a view's sizing and scrolling properties are used in conjunction with the draw context to allow views to draw themselves to their layer buffer.
The node properties establish how the views are logically arranged, but the size and scrolling properties define how they are sized proportionally to their containing view. Top, bottom, left and right offsets allow a view to be positioned inwards relatively from the bounds of their parent.
There is a resize mask property that establishes how the view's four sides are anchored to their parent. If the left side is anchored, for instance, then it will maintain its offset-left inset from its parent, regardless of how the parent is moved or resized. If right and left are both anchored, then its size becomes flexible, resizing with the parent. If the parent becomes narrower, the right and left anchors are honored and the width changes. If only the left or the right is anchored, but not both, then the width becomes fixed. The same is true for the vertical axis.
This is a form of springs and struts that was used by OpenStep, and was used by Mac OS X up until it was replaced by the more advanced (and much more computationally expensive) autolayout.
The scrolling properties affect the origin of the view's children. This allows any view which contains other views to become a peephole into a much larger virtual space. The scroll offsets can be programmatically adjusted, but most commonly they will be used by the tkscroll subclass, which instantiates and installs one or two tksbar views. When you interact with a tksbar it calls a method on its parent tkscroll view, which updates one of its scroll offsets.
There are only two scrolling/sizing related methods on tkview:
Resize causes a view to look at its own resize mask, and at its parent's current size, and to update its own width and height. After doing so, if it has actually undergone a change in size, it propagates the requirement to resize to each of its children. Those children do not immediately begin to resize themselves though, because they may not be visible. Whenever a view is required to draw itself, it checks to see if its resize flag is set. If set, it will call resize on itself, which then propagates the resize flag to its children, and so on.
Contsz stands for content size. When called, the view looks at each of its immediate children and their sizes to determine the minimum size of the virtual space they occupy. This is primarily used by tkscroll so that tkscroll can inform its tksbars about their maximum extent.
Tkview is in many ways an abstract superclass for all the other classes. It has its role in providing the node stucture and sizing, and event propagation, but a tkview by itself doesn't have much to draw. A tklabel draws its associated string, tkbutton draws its string and an optional icon, tktabs draws the tabs themselves, but tkview's only content is a background color.
A view's ability to display is divided into two methods, update and draw. There is an important reason for having two separate phases.
When a view needs to be redrawn, it marks itself as dirty using one of its display flags. And it also sets a dirty flag in the toolkit environment for this layer. (There can be more than one toolkit environment on screen at the same time, for example, the main application can have one and a utility panel floating above it can have its own too.) The dirty flag in the toolkit environment is to make the screen layer's draw cycle more efficient. Every time the screen layer has to redraw, it doesn't have to search the entire toolkit view hierarchy looking for dirty views, only to find that there aren't any. If the toolkit environment's dirty flag is set, then it knows that at least one, but maybe more than one view is marked dirty.
It is not enough to simply push the dirty views to a stack of views that need updating, because a view does not inherently know where to draw itself on screen. The draw context needs to be configured for it, relative to the draw context for its parent, relative to its parent, all the way back to the root view. The update method is responsible for this recursive search.
The application need merely call update on the root view, which is pointed to by the toolkit environment struct that the application maintains. Subclasses may implement their own update method, but they should also call their superclass's update method. The update method on tkview first checks if the view is visible. There is a visible display flag. If the view is not visible, there is nothing more to do, and it can skip recursing into its children. An entire branch of the view hierarchy gets trimmed off at an invisible view.
Next, update checks if the view's resize flag is set. If the view needs to be resized, update calls the subclass's resize method. If the parent has been resized or scrolled, it will propagate a needs bounds check flag to the child views. If the needs bounds check flag is set, then bounds checking is performed. If the view is found to be out of bounds, then just as though it were invisible, no further work needs to be done on that branch of the view hierarchy.
If the view didn't resize, and its bounds didn't change, it is possible that the view doesn't need to be redrawn at all. Its dirty flag is check to see if it needs a redraw. If it needs a redraw, it calls the subclass's draw method. The subclass draw method ought to call its superclass's draw method, but if it handles drawing over the full draw context area, it doesn't need to call its superclass's draw method, because it will overwrite the full area anyway. If on the other hand the subclass's draw method only draws partially into its draw context area, it should call its superclass's draw method first. The result is that the superclass's content and the subclass's content will be composited together.
Tkview's own draw implementation doesn't do much. But, it checks to see if the opaque display flag is set. If it is, it will clear the draw context area and fill in the color with the value set on its background color property. Then the subclass's draw routine will do a partial draw overtop of that cleared background. If the opaque flag is not set, tkview's draw won't do anything, potentially leaving content from the parent view visible within the draw context area. The subclass's draw would then be compositing overtop of content from the parent view.
Whether this view has to redraw or not, tkview's update method adjusts the context for its children, and calls update on each of its children. This is then recursive. The children go through the same process. Under typical circumstances, for example, if you merely check a checkbox, and the checkbox view gets marked dirty, The update method starting at the root view will simply find that there is nothing to do except adjust the context and pass the update method to its children. Given that nothing has been resized, nothing has been scrolled and nothing except the checkbox is dirty, the update process will sweep through the view hierarchy very quickly, with only the dirty checkbox ultimately redrawing itself.
If this was confusing, don't worry. Drawing, while taking scrolling and bounds checking, visibility, opacity and dirty flags into consideration, is the most complicated part. And the good news is that tkview implements all of that for you. If you create a subclass of tkview the only thing you need to do is implement the draw method, the draw context will be set for you, and you simply draw your content.
Hittesting has some similarities to displaying. The mouse clicks in the middle of the screen. The low-level event system has an event that indicates the type of event, the keyboard modifiers and the X and Y coordinates. But the event doesn't just know by magic which view was beneath the mouse when the event was generated. Instead, it has to figure out what view was under those mouse coordinates by successively testing. Testing starts at the root view which by definition contains all the others. If the event coordinates are outside the root view's bounds, then it didn't hit any toolkit view.
The event's X/Y coordinates are converted to text column/row coordinates and written to a set of workspace coordinates, so that the original event doesn't need to be modified. The root view's hittest routine is called, and it modifies the workspace coordinates for each child, and calls hittest on each child. Pictured below, the hit is within the root view, the outer blue view. The root view then calls hittest on its first child. That's the yellow view on the left. It sees that the hit is outside itself, so it returns null without needing to recurse any deeper. The grey and black views within the yellow view never have their hittest routine called.
Instead, the root view calls hittest on its second child, the red rectangle on the right. This sees that the hit is with itself, so it recurs and calls hittest on its first child, the purple view. That view sees the hit is within itself, but it has no children, so it returns a pointer to itself. The red view sees that its first child returned a pointer, so it doesn't bother to call hittest on its second child, the green rectangle, but simply returns the pointer that was returned to it. Eventually, the topmost child that was actually hit bubbles up all the way to the top as the recursion unfolds. Hittesting is much faster than the update cycle. The full draw context doesn't need to be adjusted, but rather adjustments are applied to the temporary row/column workspace coordinates only.
The tkview flags also include an "accepts first mouse" flag. Even if the event is inside the bounds of a view, if its accepts first mouse flag is not set, it will return null and not check any of its children, as though the hit was outside its bounds. This allows views to be made transparent to mouse activity.
It's obviously possible for a subclass to override the hittest method. But, honestly, I can't think of a good reason for doing that. 99.9% of the time, the hittest implementation of tkview will do all the work.
Although low-level events store their type, there isn't just one single "process event" method on a view. This would force every view that only supports a few types of event to retrieve the event, mask its extra properties, compare to supported types and branch to the relevant code. That's too much work.
Instead, tkview has separate methods for each type of mouse event. musdwn, musmovd, musup, musclik, etc. are methods that coorespond to the event types ldown, ltrack, lup, lclick, etc. A routine in the toolkit module, tkmouse, makes the initial hittest call on the root view. It then calls the appropriate responder method on the view that was hit.
Subclasses of tkview that actually do respond to mouse events merely need to override the methods for the event types they support. For event types they don't support, they just point their jump table entries to their superclass's jump table entry for those methods. For subclasses that don't support an event, the method call jumps up the inheritance chain to tkview's implementation, which by default calls the same method but on the next responder.
This is the fundamental mechanism that propagates events up the view hierarchy. For mouse events the next responder is always the view's parent view. So it uses the node properties to lookup the parent node, and calls the same responder method on it.
There are some gotchas, things the typical user of a GUI wouldn't notice just using them. A mouse down event needs to seek out the view that was hit. The mouse down often has some intermediate effect on its target. For example, mouse down on a button and it changes appearance to provide feedback that you've hit it. But the button's action doesn't get triggered until you complete a click on it. If you move the mouse off the button before the mouse up, you don't want the mouse up event delivered to its own hit target, leaving in the lurch the original button that got the mouse down. Instead, what typically happens is that the mouse up, no matter where it occurs, gets routed back to the view that was hit with the mouse down. The button can restore its appearence as part of processing the mouse up event.
Then, and only then, if the mouse down and the mouse up both occurred within the bounds of the same view will that view be sent the click event.
Here's how that's handled in toolkit: A mouse down occurs, it performs a hittest. A pointer to that view is written to the toolkit environment as the first mouse view. When the mouse up event arrives, the mouse up event is sent to the first mouse view. A hittest is performed again. If the newly hit view is different than the first mouse view, then any subsequent low-level click event will not be delivered at all. If the mouse up hit view matches the first mouse view, then the click event will be delivered to that view.
Keyboard events are a whole other kettle of fish.
When you press a key on the keyboard, it is not inherently related to any particular place on the screen. So you need to know where to route the key event to.
Tkview has another flag, "accepts first key". Tkview musclik method calls the method setfirst on itself. This checks the accepts first key flag. If it's low, it just returns and nothing happens. But if it's set, setfirst looks up the current first key view from the toolkit environment, and calls setfirst on that view with an argument to tell it that it's losing it's first status. There is also a first display flag. The view losing its first status lowers that flag and returns. Then the view receiving first status raises its own first flag, and installs a pointer to itself into the toolkit environment.
Subclasses may override setfirst. Tkctrl, for example, overrides setfirst. Tkctrl adds a disabled flag. So a control that typically could accept first key can be disabled, and it can refuse first status leaving the previous first key view still the first.
Additionally, some views have a different appearance when they are first. Text fields, for example, have a focused appearance. These subclasses are responsible for marking themselves as dirty when they accept first key status. That will cause them to get redrawn, and their draw routine can observe their own first display flag to decide how to draw themselves.
Next we have distribution of key events. C64 OS's input module divides key events into two kinds, key command events and printable key events. If a key command event becomes available, the dokeyeqv method is called on the first key view. If a printable key event becomes available, the method keypress is called on the first key view.
From the first responder, key events are propagated mostly the same way as mouse events. However, for key events there are cases where the next responder should not just be the parent node. For this, there is the tkview property, nextresp. Nextresp is checked first. If it's set it gets used as the next responder. If it's null, the next responder falls back to the parent node.
Let's look at an example. You click a tklabel, it doesn't take first key, nor handle the click in any other way. The click is propagated up the view hierarchy. The tklabel is ultimately embedded within a tkscroll view. The tkscroll accepts first key, but doesn't have a unique visual appearence when first, so it doesn't get marked dirty or redraw itself. But its act of taking first key may have deprived first key from some other text field somewhere. That text field loses first key and marks itself as dirty. The text field gets redrawn as not in focus on the next update cycle.
The user pushes a cursor down key, that generates a printable key event (single navigation keys, i.e. cursor keys, return, delete, insert, etc. are propagated as printable key events not as key commands. See: The Event Model) Toolkit looks up the first key view and calls the keypress method on the tkscroll. It identifies it as a cursor down PETSCII code, adjusts its scroll offsets down by one, sets its bounds check flag and marks itself dirty. On the next redraw cycle, the content inside the tkscroll gets redrawn down a row, and the tksbars redraw reflecting the new position.
Bingo bango, all tkscrolls support keyboard navigation without any intervention at all from the application developer.
Subclasses of TKView
As you can see, tkview does a lot of work. Across the 5 categories of functionality that it supports, many subclasses of tkview only need to make relatively minor tweaks before passing off the rest of the work to their superclass.
Let's look at a couple of examples of subclasses of tkview and what they need to do to implement their special behavior.
Let's start with tksplit, because it's a very simple example.
The point of a split view is to manage for you the arrangement of two views either side by side or stacked one above the other. Tksplit inherits directly from tkview, which by means of its node properties, addchild method and offset and anchoring properties, supports multiple children in various layouts.
The main difference is that a tksplit allows the user to adjust the sizes allocated to two children, and therefore, requires that there not be more than two children. In order to accommodate the limit of two children tksplit implements its own version of addchild. But all it does is counts its children. If its child pointer is null, then it has no children, so it calls its superclass's addchild method. If it has a child, it checks that child's sibling pointer. If it's null, it has only one child, so it calls its superclass's addchild method. Otherwise, it already has two children so it raises an exception.
That's all it needs to do to limit to two children. Very easy.
Next, it needs some additional properties. One to indicate whether it's a horizontal split or a vertical split. In addition to calling its superclass's addchild method, it needs to set the anchoring and sizing properties of the child views automatically. This is very easy. If it's a vertical split (one child above another), it sets the anchoring of the first child to left, top and right, with a height of half its own height. It sets the anchoring of the second child to left, bottom, right, with a height of half its own height. If its height is odd, that will leave a one-row gap. If it's height is even, it can subtract one from the height of either the first or second child. The two children will now draw themselves, one above the other.
Similarly, if the split is set for horizontal, the first child's anchoring can be set to top, left and bottom, with a fixed width of half the parent's size. And the second child can be anchored to top, right and bottom, with a width half the parent's size, possibly minus one to leave a gap.
The ability to move the split involves implementing responder methods for musdwn, musmovd and musup. When it has its musdwn method called, it needs to check to see if it is the first responder. It can do this by comparing itself, this, against the Toolkit environment's hit view pointer. If it's not the first responder, it just propagates the event. But if it is the first responder that's because the user has moused down somewhere on the gap between its two children. It sets an internal flag to indicate that it's in split move mode. When its musup method is called it clears that flag.
Musmovd events only occur while dragging. So if it has this method called, it needs to reference its own split move mode flag. If it's not currently moving the split, just propagate the musmovd event. But if it is moving the split, it just sets the height or width of its two children, depending on whether it's a vertical or horizontal split, sets the resize flag on both its children and then marks itself dirty. Upon redraw one of the two children will be larger than before and the other smaller. How all of their children respond is not the concern of this tksplit.
There is more that can be done, but this is the basic gist of it. A surprisingly small amount of code is necessary to implement this on top of what is already there.
The grippy here is drawn at the bottom. Additional space is left around the child views only in this diagram so you can more easily see how they are laid out.
Some flourishes that could be added include, drawing a grippy inside the split gap. Should it draw it on only one end, should it try to center it, those are optional. Additionally, if the bounds of the tksplit are changed, how should it reallocate space to the two children? Should it get a ratio of their two sizes, and recompute how big each should be given the new size of the tksplit? Or should it try to preserve the size of one and flex the size of the other? Those could be configurable options on the tksplit view.
Here's what's really cool. The two child views that you add to the tksplit view can be any other subclass of tkview. You could pass in an actual tkview, to which you can then layout multiple children, but you could also pass a tktabs view into the split, or you could pass another tksplit into the tksplit. You have a lot of flexibility because all the views descend from tkview, so almost anything can be passed in as one of the children of a tksplit.
Let's look at just one more subclass of tkview, another relatively simple modification, tktabs. Tktabs is more complicated than tksplit, to be sure, but it is still a surprisingly minor modification over what tkview already gives us.
We're all familiar with tabs, but in a technical nutshell, a tktabs view manages the layout of multiple children. Each child that gets added will be set to fill the tktabs view, except inset by one row from the top. Into the top row it will draw a series of labels, highlighting one of them for the focused tab. Each child view is associated with one tab, and all children except the one for the tab in focus are marked as not visible. Clicking a tab then changes how the tab labels are colored, and the previously visible child is marked not visible and the child for the newly selected tab is marked visible. And that's pretty much it.
There are some things we need to think about though.
Adding child views to a tktabs view is easy. The parent/child/sibling node properties of tkview support that. But now, in addition to the parent having several children, it also needs an associated tab per child. Each tab needs to draw a string of text to identify it. And we need to keep track of which tab is in focus.
In practice, having 256 tabs is impossible, because we'd run out of memory long before that. Also, we have a screen that's only 40 characters wide. I've opted to limit the number of tabs in a tktabs view to 10. Each tab has a tab separation character for visual style, so if the tktabs view had the full screen width, that would be (40 / 10) - 1 = 3 characters left to draw each tab label. A tktabs view could be wider than the screen, by embedding it inside a tkscroll view, for example. However, there are other reasons for limiting the number of tabs to something small and reasonable.
When redrawing and when hittesting, it is necessary to know the width of each tab. This width could be computed from scratch each time draw or hittest is called, but this computation is kind of slow. It's much faster to only compute the tab widths when they change size, such as when the view gets resized or a new tab gets added. But if we're precomputing the tab widths, there needs to be somewhere to store the tab widths. The sensible place to store the tab widths is in some properties of the tktabs view. But, then, how to do that. Some tktabs views will have only 2 tabs. If they needed to support 100 tabs would that waste 98 bytes? With a limit of 10 tabs, we're wasting a maximum of only 8 bytes.
There are three properties that tktabs adds to facilitate drawing and hittesting:
- curtab (Current Tab, index of the currently selected tab)
- tabcnt (Tab Count, number of tabs there currently are)
- tabsz (Tab Size, an array of 10 bytes, 1 width byte for each of 10 tabs)
It's possible to compute how many children a view has. Tksplit does this to determine if it has zero, one or two children. But the more children a view has the more expensive it is to count them.
Tktabs implements its own version of addchild. It checks tabcnt, if it's already 10, it raises an exception. Otherwise, it increments tabcnt, calls the superclass addchild method, and then sets the new child's properties:
Anchoring: top, left, bottom, right
Offtop: one row
This makes every child the same size as the tktabs, and occupy the same space, except for leaving one row at the top for the tabs to be drawn. If this child is not the first child, it gets marked as invisible.
Presumably multiple children will be added one after the next by the code that is initializing the application's user interface. More than one tab can be added before it goes through an update/draw cycle. You can also add a tab a runtime. When tabs get added the tktabs view has its needs resize flag set.
When the update cycle happens, resize will be called on the tktabs, which is the tktabs' own resize method. First it calls its superclass resize method to size itself and to propagate the resize to each of its children. Lastly, it takes its own width and divides it by the tab count. It distributes the remainder one each to the first N tabs, and writes these tab widths into the tabsz array.
Next, the tktabs will have its redraw method called. It needs to implement its own redraw method. It calls its superclass redraw method which propagates the redraw call to each of the children. But, only one of those children is visible, which will draw itself and all of its own children, etc. And lastly, our tktabs view will draw its tab bar along the top.
It draws the tab bar by configuring the draw context, and writing out characters up to the length alloted to the tab by the tabsz array. It gets its color by checking its curtab property to see if this is the focus tab or not, and then looking up the color in the customizable tkcolors palette. (I recently tweeted about the new Themes utility that lets you visually customize the toolkit UI colors.)
We're missing something though. We know how wide to draw a tab. We know what color to draw the tab, based on its selection status and the tkcolors palette. But, how do we know what string to draw onto the tab? For this, in steps the delegation pattern again. I love the delegation pattern. I've used it to great effect in the C64 OS menu system, and it really simplifies coding, and takes the control of behavior out of the view and puts it into the controller, where it belongs. You can read about how delegation is used by the menu system in the post, Menu Systems, a comparison.
Tktabs has both a delegate property, and a setdel method for setting the delegate. In macOS or OpenStep, the delegate would be a controller object. But remember, in C64 OS, controllers are not object oriented. Instead there is a tktabs delegate structure. It is just 10 bytes consisting of pointers to 5 routines. The routines are:
We'll return to the wills and dids in a minute. When it needs to get a string for a tab, tktabs calls the tabstr routine, passing as the sole argument the index to the tab being drawn. It's up to your app how it will generate the string to appear on that tab. Remember, even though your application code, the controller, is not object oriented, the this pointer is still pointing at the tktabs view currently being drawn. So one tabstr delegate routine can be used to handle more than one tktabs view. The delegate routine returns a pointer to a c-string (a null terminated string), and tktabs draws out the first so many characters of it that it has room to fit.
The delegation pattern is very flexible while maintaining the simplicity of the tktabs view. Perhaps something changes in the child view of a tab that is out of focus. Maybe you have chat messages that will display in an out of focus tab. When the content changes, you want to subtly notify the user. So your app's code marks the tktabs as dirty. But when it redraws and asks for the string content for that tab, you can include an extra character at the start of the string, like a checkmark, or an asterisk. Bingo, all of a sudden an asterisk appears on the tab before the text.
Or here's another possibility. The tabstr routine can lookup the size of the tab to be drawn and use that to customize how it will label the tab. A tab that gets crunched down small could change a full label string to a sensible abbreviation, or even to just a single character icon. These features are in the hands of the controller, your app, leaving the tktabs view generic but malleable.
Lastly there is the issue of tab selection. Tktabs must implement its own musclik routine. It can then check to see if it is the first responder, just as the tksplit did. If it's not the first responder, that's because the click originated in one of its children, and has been propagated to it. It can continue to propagate the click event by calling its superclass musclik method.
The only way for it to be the first responder, though, is if the mouse click originated somewhere on its tab bar. It then compares the click's coordinates to the tabsz array to determine which tab was clicked. Rather than just setting the new current tab, changing which child view is visible, and marking itself for redraw, tktabs is going to do something a bit more clever. It calls settab on itself, passing as an argument the index of the tab that was clicked.
First, if the tab that was clicked is already the current tab, there is nothing to do and it can just return. If another tab was clicked, we still don't want to just brute force change tabs and mark for redraw. That would work, but it would be better if the controller had some insight into when tabs are changing, and some say over whether tabs are allowed to change.
For this, we return to the delegate pattern again.
The delegate structure has already been set. It was set so that the controller could provide strings for the tab labels. Here we have the 4 more delegate routines. Will blur is called first. The delegate routine has access to this which is the tktabs view, and can look up the currently selected tab. If for any reason, specific to the behavior of the application, the tab should not be allowed to become unselected, the routine returns with the carry set. Settab will abandon the attempt the change tabs then and there.
Returning with the carry clear allows settab to proceed. Next it calls Will Focus and passes as an argument the tab index that will be selected. Same deal, the application can, for any reason whatsoever, for reasons we can't foresee, refuse to allow the clicked tab to be selected by returning with the carry set. There are system constants SEC_RTS, CLC_RTS and RAW_RTS, that point to sequences of SEC RTS, CLC RTS or just plain RTS that doesn't affect the carry. These are handy for things like delegate routines, or screen layer structures where you don't want to implement a routine, but you need to provide it with something, and you always want it to return with the carry a certain way. For instance, if you create a tktabs delegate, and you don't care about willblur or willfoc, you just always want the tabs to be allowed to change, you can set those delegate routine pointers to CLC_RTS. It always returns with the carry clear, which means it always allows.
If both the blur and focus are allowed, settab then goes ahead and applies the changes. It sets curtab to the new selected index. It hides the currently focused child view and unhides the child view associated with the new tab. Marks the tktabs as dirty.
Lastly, it calls both Did Blur and Did Focus delegate routines. The did delegate routines do not have the ability to prevent an action with a return value. They are merely notifications that the change occurred. This can be useful. For example, maybe the application is able to stop processing certain events while a tab is out of focus, but needs to start up again when the tab becomes selected. These notifications let the app know about these changes. The application doesn't necessarily know that a tab will become blurred simply by having willblur called. Because, it doesn't know if some other part of the program will prevent the change via the willfoc call. This set of routines gives full insight and behavioral control over to the application, while hiding all of the details of how tktabs works.
I realize that there is a lot to digest there in the behavior of tktabs. But the main take away point is that, considering all that tktabs can do, there is actually very little to its implementation. Most of the work is handled by tkview, and the delegate routines. A little bit of hittesting, a little bit of drawing for the tabs themselves, and an override of addchild to limit and auto configure the children as they get added. And that's about it.
I'll leave it there for today. We're at 10,000 words. And in a future post we'll return to talk about the internal implementations of some other classes.
Stay safe! Stay healthy! And if you have to go out, maintain a safe distance. But, what better time than to sit at home and play with your C64.
Do you like what you see?
You've just read one of my high-quality, long-form, weblog posts, for free! First, thank you for your interest, it makes producing this content feel worthwhile. I love to hear your input and feedback in the forums below. And I do my best to answer every question.
I'm creating C64 OS and documenting my progress along the way, to give something to you and contribute to the Commodore community. Please consider purchasing one of the items I am currently offering or making a small donation, to help me continue to bring you updates, in-depth technical discussions and programming reference. Your generous support is greatly appreciated.
Greg Naçu — C64OS.com