LabeledTextEdit (and other labeled controls)

Previous Topic Next Topic
 
classic Classic list List threaded Threaded
1 message Options
Reply | Threaded
Open this post in threaded view
|

LabeledTextEdit (and other labeled controls)

Louis Sumberg
Here is my first cut at implementing semi-lightweight labeled controls.
Bill Schwab had made a comment sometime recently about labeled text edits
and that got me thinking about it, or rethinking, because this is something
that I've run into quite a bit over the years.  That is, the tedium of
creating forms where you're repeatedly adding, resizing, and repositioning
controls (of whatever kind) and their associated labels (static text).

Having two windows controls for each entry field also seems excessive, not
just in terms of Windows resources, but in view complexity for the
developer.  If you have a window with eight fields on it, it's easier to
look at and work with it if you see eight controls, rather than sixteen.

My initial approach to this problem is to create a subclass under TextEdit,
called LabeledTextEdit, give it label and alignment aspects and have the
control itself draw its label.  The label is just a simple string, drawn in
the font of its associated control.  The alignment can be to the left of the
control (centered vertically or at the top) or on top of the control (at the
left or centered in the middle).  This gives #leftCenter, #leftTop,
#topLeft, and #topCenter, though I suppose one could easily argue that each
and every end and midpoint of the control's rectangle, both inside and
outside, should be included.  Since we'll need to draw, erase and redraw the
label, we need to track its location and size, which means a private
instance variable that will hold its bounding rectangle.  Here's the class
definition and comment.

TextEdit subclass: #LabeledTextEdit
   instanceVariableNames: 'label labelRect labelAlignment'
   classVariableNames: ''
   poolDictionaries: ''
   classInstanceVariableNames: ''

This class adds a string label to its Windows control.  The label has a
bounding rectangle and it may be aligned in various ways, relative to its
Windows control.

Instance Variables:
   label          <string> label to be displayed with the receiver.
   labelRect      <rectangle> the bounding rectangle of the label, relative
to its parent view.
   labelAlignment <symbol> e.g., #topLeft (above the control, on the left
side), #topCenter (above the control, in the middle), #leftTop (to the left
of the control, at the top), and #leftCenter (to the left of the control, in
the middle).

On the class side, add the following so that label and labelAlignment
aspects show up in the ViewComposer:

labelAlignmentAspects
   "Answer the set of symbols that an instance of the receiver can align its
label to."

   ^#(#leftTop #leftCenter #topLeft #topCenter)

publishedAspectsOfInstances
   "Answer a Set of the aspects published by instances of the receiver"

   ^super publishedAspectsOfInstances
      add: (Aspect string: #label);
      add: (Aspect choice: #labelAlignment from: self
labelAlignmentAspects);
      yourself

Initially I had an #initialize method for the instance variables but I
decided to go with lazy initialization because I might want to generalize
the class and incorporate it directly in TextEdit (or even ControlView),
rather than as a subclass.  This also meant adding default methods for the
label name and alignment.  Note that labelRect is a private instance
variable so there are no accessors.

defaultLabel
   "Answer the default label text of the receiver."

   ^'Label:'

defaultLabelAlignment
   "Answer the default alignment of the receiver's label."

   ^#leftCenter

label
   "Answer the label of the receiver."

   ^label notNil ifTrue: [label] ifFalse: [self defaultLabel]

label: aString
   "Set the label of the receiver to aString."

   self eraseLabel.
   label := aString.
   self drawLabel

labelAlignment
   "Answer the alignment of the label."

   ^labelAlignment notNil ifTrue: [labelAlignment] ifFalse: [self
defaultLabelAlignment]

labelAlignment: aSymbol
   "Set the alignment of the label."

   self eraseLabel.
   labelAlignment := aSymbol.
   self drawLabel

The drawing methods are as follows:

eraseLabel
   "Private - erase (invalidate) the rectangle containing the receiver's
label."

   labelRect notNil ifTrue: [
      self parentView invalidateRect: labelRect.
      self invalidate] "This line seems needed to force the above redraw."

drawLabel
   "Private -  Draw the receiver's label."

   | canvas |
   (canvas := self parentView canvas) isNil ifTrue: [^nil].
   canvas setBkMode: TRANSPARENT; font: self labelFont.
   (labelRect := Rectangle origin: self position extent: (canvas textExtent:
self label))
      moveBy: (self perform: (self labelAlignment, 'LabelOffset') asSymbol).
   canvas text: self label at: labelRect origin.
   canvas free

Notes on the above: labelRect holds the current bounding rectangle of the
label, relative to the parent.  Since alignment is always outside the bounds
of the Windows control (above or to the left), to erase the label we need to
invalidate the portion of the parent's view that labelRect occupies.  To
draw the label we recalculate labelRect and then draw the text within the
new labelRect bounds.  Recalculating labelRect is done by first positioning
labelRect at the control's origin, giving it an extent that's the label's
computed extent, and then moving it to the correct alignment position.

Helper methods that #drawLabel uses are:

leftCenterLabelOffset
   "Private - return a point that is the distance from the Windows control's
origin to the label's origin."

   ^labelRect width negated @ ((self rectangle height - labelRect height) //
2)

leftTopLabelOffset
   "Private - return a point that is the distance from the Windows control's
origin to the label's origin."

   ^labelRect width negated @ 0

topCenterLabelOffset
   "Private - return a point that is the distance from the Windows control's
origin to the label's origin."

   ^((self rectangle width - labelRect width) // 2) @ labelRect height
negated

topLeftLabelOffset
   "Private - return a point that is the distance from the Windows control's
origin to the label's origin."

   ^0@labelRect height negated

labelFont
   "Answer the label's font."

   ^self font notNil
      ifTrue: [self font]
      ifFalse: [self parentView font notNil
         ifTrue: [self parentView font]
         ifFalse: [SmalltalkSystemShell defaultFont]]

The label font is derived from the Windows control's font.  I'm not sure if
#labelFont above is entirely correct and/or could be simplified, but it
seems to return reasonable results (on my screen at least).

Finally, the event methods.  WmPaint: is used to draw the label when it
needs updating (e.g., another window that covered it is gone or has been
moved).  OnPositionChanged: is used to redraw the label (erase the old and
draw the new) when the Windows control is moved or resized.

wmPaint: message wParam: wParam lParam: lParam
   "Private - Controls do their own painting so only allow the default."

   self drawLabel.
   ^super wmPaint: message wParam: wParam lParam: lParam

onPositionChanged: aPositionEvent
   "Handle a window position change event (move or resize).
   Implementation Note:  Labeled  controls are partially transparent and do
not
   redraw some of their area, so when they are moved they leave behind
traces of their
   former label. We therefore erase the label now."

   self eraseLabel.
   ^super onPositionChanged: aPositionEvent

To add a resource for the new class, evalute:

   LabeledTextEdit makeResource: 'Labeled text' inClass: TextPresenter.

To try it out, bring up ViewComposer on a new or existing shell or dialog
and then add a TextPresenter.Labeled text resource to it (or mutate a
TextEdit to a LabeledTextEdit).  You should see 'Label:' appear on the left
side of the textedit portion and you can change the label and labelAlignment
aspects directly in the VC.  Move or resize the control and the label moves
with it.

You can also subclass other controls using the exact code above (except for
the class names of course).  I did this with ListView, ListBox, ComboBox,
DateTimePicker, and MultilineTextEdit.  The only changes I made were to
defaultLabel and defaultLabelAlignment in some of the classes.
(LabeledListView and LabeledListBox's default alignment is #topCenter,
default label is 'List'; LabeledDateTimePicker's defaultLabel is 'Date:';
and LabeledMultilineTextEdit's default alignment is #leftTop.)  One added
bonus to all of this is that when I look at a form in the VC, on the lower
left side (View Hierarchy) I see a list of my controls without the
"extraneous" static textedits cluttering up the list.  See
http://www.sirius.com/~lsumberg/Dolphin/LabeledTransDlg.jpg for an example
(7 controls instead of 12).

There are a couple of drawbacks that I know of.  For one thing, there are no
accelerator keys for the labels.  For example, with a static textedit whose
text is '&Name', you can go directly to the control next to it by typing
Alt-N.  You can't do that with LabeledTextEdit and I have no idea if it can
be implemented in this design.  For some people this could be a killer
requirement.  (Hmm, on second thought, it might not be too hard to do
this -- e.g., trap keystrokes to the control and send them to some
accelerator object that says change focus to that other control or go on
with that keystroke.)  Is this important?  Any ideas on how to implement it?

The other thing is that these labeled controls do not display well with most
layouts since the label is outside the known boundary of the control.  For
example, a LabeledTextEdit that has a #north arrangement and #leftCenter
labelAlignment will have the text edit portion filling the upper part of the
parent view, but its label will be off the view, all the way to the left.
Similarly, if its arrangement is #east, the label will print on whatever
view is arranged in the center.  For me this has not been much of a problem
since for most forms that I use static labels on, I use a FramingLayout and
these labeled controls seem to work fine with that.  Still, if there were a
way to work around this, where the containers or layouts recognize the
labeled offsets, other layouts could be used.  (Hmm again ... I've tinkered
a bit with one approach that works.  See
http://www.sirius.com/~lsumberg/Dolphin/LabeledAcme.jpg for an example --
notice there are no container views.)  Again, this could simplify views by
reducing the number of controls needed in each window.

Some thoughts on bells and whistles include adding a labelFont aspect (real
easy to do) and adding wordwrap/multiline capability.

Note that the code above is for Dolphin Version 4 -- a few small changes are
needed to make it run in earlier versions.  As always, comments, discussion,
suggestions and such are verrrry welcome!  If I'm reinventing the wheel
(it's hard to believe no one has done this before) or offbase, please let me
know.

-- Louis

P.S.  You can download a package of labeled controls from
http://www.sirius.com/~lsumberg/Dolphin/LabeledControlsV4.pac and then
install them through the package browser.  If you have trouble adding the
LabeledListView resource to a view, do the following: Bring up a new VC,
open a ListPresenter.Enhanced list view, mutate it to a LabeledListView,
evaluate the following in the Workspace at the lower right:
   self primaryColumn parent: self
and Save As ListPresenter.LabeledListView.  You can also just edit an
existing view that you have and wherever you have a static text, mutate the
associated windows control to its labeled equivalent (and delete the static
text *s*).