In the last installment of From the Couch we created DrawControl – a simple drawing application. In this installment we’ll add selection and deletion of drawing items.
The basic idea is that you should be able to click on a drawing item (e.g. a rectangle) to “select” it. Or, alternately, you should be able to choose a rectangular portion of the screen in order to select all the items within that rectangle. As is fairly standard with drawing programs, we’ll indicate selected items by drawing “handles” on them. For now, these handles simply mark selected items, later, they will be used to resize items.
The first step is to add a new drawing tool (tracker) for selection. After refactoring DrawRectTracker so we can inherit most of its behavior, here is the resulting DrawSelectTracker:
DrawRectTracker { New(hwnd, canvas) { super(hwnd, False) .canvas = canvas; } Point(x, y) { .canvas.SelectPoint(x, y) return False } Rect(x1, y1, x2, y2) { .canvas.SelectRect(Min(x1, x2), Min(y1, y2), Max(x1, x2), Max(y1, y2)) return False } }
The argument to New is a reference to the canvas rather than the item to be created as with the other trackers. Point and Rect simply delegate the work to the canvas and return False to tell DrawRectTracker that no new item was created. Rect uses Min and Max to ensure the points are the top left and bottom right. This is not done by DrawRectTracker because for in some cases the direction the rectangle was drawn is significant.
Then we need to add the new select tool to DrawControl:
Controls: (Vert (ChooseList (Select, Line, Rectangle, Ellipse, Arc, Text, Image, X)) DrawCanvas ) NewValue(value) // sent by ChooseList { .canvas.ClearSelect() switch (value) { case 'Select' : .canvas.SetTracker(DrawSelectTracker, .canvas)
We also clear the selection when we change tools.
Here are the new CanvasControl methods:
SelectPoint(x, y) { .ClearSelect() for (i = .items.Size() - 1; i >= 0; --i) if (.items[i].Contains(x, y)) { .Select(i) break } } SelectRect(x1, y1, x2, y2) { .ClearSelect() foreach (value item in .items) if (item.Within(x1, y1, x2, y2)) .selected.Add(item) .Repaint() } ClearSelect() { if (.selected.Empty?()) return .selected = Object() .Repaint() } Select(i) { .selected.Add(.items[i]) .Repaint() }
We also need to initialize the new “selected” member variable to be an empty object in New:
New() { .CreateWindow("SuWhiteArrow", "", WS.VISIBLE) .SubClass() .items = Object() .selected = Object() }
Since the order of .items is from “bottom” to “top”, and we want to select the top-most item when we click, SelectPoint loops through the items in reverse order (i.e. from top to bottom), stopping as soon as it finds an item. On the other hand, SelectRect selects all the matching points, so it can simply use foreach.
This code now depends on items implementing Contains(x, y) and Within(x1, y1, x2, y2) to determine if an item contains a point, or if it is within a rectangle. Rather than simply repetitively implementing these methods for all items, we’ll create default versions in CanvasItem:
Contains(x, y) { r = .BoundingRect() return r.x1 <= x and x <= r.x2 and r.y1 <= y and y <= r.y2 } Within(x1, y1, x2, y2) { r = .BoundingRect() return x1 <= r.x1 and r.x1 <= x2 and y1 <= r.y1 and r.y1 <= y2 and x1 <= r.x2 and r.x2 <= x2 and y1 <= r.y2 and r.y2 <= y2 }
Now items simply have to implement BoundingRect, which we’ll use for other things as well. For example, here is CanvasRect’s:
BoundingRect() { return Object(x1: .left, y1: .top, x2: .right, y2: .bottom) }
Finally, we need to paint handles on the selected items so we can see what is selected.
PAINT() { hdc = BeginPaint(.Hwnd, ps = Object()) foreach (value item in .items) item.Paint(hdc) foreach (value item in .selected) item.PaintHandles(hdc) EndPaint(.Hwnd, ps) return 0 }
In CanvasControl, after painting each item, we then paint the handles for selected items. This ensures that the handles will be painted on top and won’t be hidden by other items. Again, we’ll provide a default implementation in CanvasItem:
PaintHandles(hdc) { oldrop = SetROP2(hdc, R2.NOTXORPEN) oldbrush = SelectObject(hdc, GetStockObject(SO.BLACK_BRUSH)) .ForeachHandle() { |x,y| Rectangle(hdc, left: x - 3, right: x + 3, top: y - 3, bottom: y + 3) } SelectObject(hdc, oldbrush) SetROP2(hdc, oldrop) }
We draw the handles with the exclusive-or mode so we can see them regardless of the background.
Rather than hardwire the positions of the handles, we call an iterator method to loop through the handles. This type ofiterator method is common in Smalltalk and Ruby. We supply a default implementation in CanvasItem that puts handles at the corners and middle of the sides of the bounding rectangle:
ForeachHandle(handle) { r = .BoundingRect() handle(r.x1, r.y1) handle(r.x1, r.y2) handle(r.x2, r.y1) handle(r.x2, r.y2) x = (r.x1 + r.x2) / 2 y = (r.y1 + r.y2) / 2 handle(x, r.y1) handle(x, r.y2) handle(r.x1, y) handle(r.x2, y) }
This can be overridden by specific items if we want different handles. For example, CanvasLine just puts handles at the ends of the line:
ForeachHandle(handle) { handle(.x1, .y1) handle(.x2, .y2) }
Here is the result (all items selected):
The only problem is that we can’t do anything with the selected items! Since moving and resizing are a little too involved for this installment, we’ll just implement delete, via the delete key. The first step is to catch the keyboard message in DrawCanvasControl:
KEYUP(wParam) { if (wParam is VK.DELETE) .DeleteSelected() return 0 }
The only catch is that in order to receive keyboard messages the DrawCanvasControl has to have the focus, which will often be on the ChooseList instead. To remedy this, we can add a SetFocus to the mouse down handler:
LBUTTONDOWN(lParam) { SetFocus(.Hwnd) .dragging = True x = LOWORD(lParam) y = HIWORD(lParam) .tracker.MouseDown(x, y) return 0 }
Then we add DeleteSelected to CanvasControl:
DeleteSelected() { foreach (value item in .selected) { .items.Remove(item) item.Destroy() } .selected = Object() .Repaint() }
Now we can at least do something with the selected items!
One last point – because we added new required methods for canvas items, CanvasStockObject will no longer work properly. We could implement the required methods and simply delegate to the wrapped item, but a better solution is to simply delegate all unrecognized methods to the item. This way, we don’t have to worry about future new methods.
Default(@args) { return .item[args[0]](@+1args) }
(This idiom is covered in the Idioms section in the Appendix of the Users Manual.)
If you want to try this code, you can download drawlib2.zip, extract drawlib.su, load it from the command line with:
suneido -load drawlib
Use the library from LibView, and then run DrawControl().
Note: This drawlib includes new versions of the Canvas and Draw code that is included in the April 22 release of stdlib. This new version of the code will be included in the next release.
Here are a few ideas for enhancements. I’m sure you can think of additional ones:
- Add Select All (CTRL+A)
- Support multiple selection with SHIFT or CTRL clicking. Hint: Use GetKeyState.
- Allow “grouping” and “ungrouping” of multiple selected items. A set of items that has been “grouped” becomes a single composite item that can be selected, moved, etc. Hint: Create a CanvasGroup canvas item with a bounding box that covers all the included items. To group, replace the individual items with a CanvasGroup. To ungroup, replace the CanvasGroup with the individual items.
- Improve Contains and Within for non-rectangular items such as ellipse and line. Hint: Windows has “region” functions that can be used with shapes like ellipse. For lines dust off your high school geometry to figure out the distance from a point to a line.
- Implement a tracker to allow drawing polygons. Either implement BoundingRect or, more accurate versions of Contains and Within. Don’t forget to implement ForeachHandle to put handles at the vertices of the polygon.
Future installments will cover moving and resizing selected items, and saving and loading drawings.
Andrew McKinlay
Suneido Software