bekwam courses

Selecting Shapes in TornadoFX

December 30, 2017

This article shows how to implement multi-select in a shape-driven JavaFX UI. A shape-driven UI can be a productivity boost because it allows the user to organize their mental image of a business problem without conforming to a rigid table or hierarchy. For instance, the user can group shapes representing tasks in a "started" section of a work area. Or, the user can groups shapes representing investments in a "risky" section of a work area.

Once these ad hoc groups are formed, the user will need to select individual items and groups of items. Popular applications often support two mechanism for selecting groups.

  1. Key modifier - Control or Shift plus point-and-click selects multiple items
  2. Lasso - Dragging on the work area and forming a rectangle selects items contained within the area

This example implements both mechanisms. For single select, the user points-and-clicks to select an item. Selecting on the work area background clears the selection. If the user presses the Control or the Shift key while pointing-and-clicking, multiple items are selected. While in this key modifier mode, if the user selects an already selected item, that item is de-selected. The user can also lasso a selection. Dragging on the work area background forms a rectangle and any items fully contained in the rectangle are selected.

The following video steps through the selection mechanisms. First, single-item selections are made. Next, multi-select is performed, first with the Control key pressed and then with the Shift key pressed. Finally, the lasso technique is demonstrated with a rectangle formed in several directions. During the lasso section, non-selections are shown from partial drags.

The selections are recorded as a border on the circles and also in a flat ListView to the right. The ListView shows how the graphics code -- mouse positions, shapes -- is translated into the functional requirements. The RED, YELLOW, BLUE, and GREEN that appear to the right are produced with information stored in the shape. This information is a custom property "circleColor" that could be expanded to include other properties, identifiers, or complex objects.

Structure

The program starts with a primary View called SelectingView.

class SelectingView : View("Selecting App") {

    var workArea : Pane by singleAssign()

    val circles = mutableListOf<Circle>()

    val selectedCircles = FXCollections.observableArrayList<String>()

    var lassoRect : Rectangle by singleAssign()

    var initDrag = false
    var dragStart : Point2D? = null
	
	

workArea is the left-hand background on which selections are made. lassoRect is an always-present -- though not always visible -- cursor tracker. circles is a convenient list of the circle Nodes added to the workArea. selectedCircles represents the business model selections made in the workArea. initDrag and dragStart are two internal variables used in the handlers involved in dragging.

This is the Scene Graph of the View.

    override val root = hbox {
        anchorpane {

            workArea = pane {

                fun createCircle(centerX : Int, centerY : Int, c : Color, prop : String) : Circle {
                    return circle(centerX, centerY, 50) {
                        fill = c
                        addClass(SelectingStyles.circleItem)
                        circles.add(this)
                        properties["circleColor"] = prop
                    }
                }

                addClass(SelectingStyles.workArea)

                createCircle(100, 100, Color.RED, "RED")
                createCircle(220, 100, Color.BLUE, "BLUE")
                createCircle(100, 220, Color.GREEN, "GREEN")
                createCircle(220, 220, Color.YELLOW, "YELLOW")

                label("Ctrl, Shift, or Drag to Multi-Select") {
                    padding = Insets(2.0)
                }

                lassoRect = rectangle {
                    isVisible = false
                    addClass(SelectingStyles.lassoRect)
                }

                anchorpaneConstraints {
                    topAnchor = 0.0
                    bottomAnchor = 0.0
                    leftAnchor = 0.0
                    rightAnchor = 0.0
                }

                addEventFilter(MouseEvent.MOUSE_PRESSED, ::press)
                addEventFilter(MouseEvent.MOUSE_DRAGGED, ::lasso)
                addEventFilter(MouseEvent.MOUSE_RELEASED, ::stopLasso)
            }

            padding = Insets(10.0)

            hboxConstraints {
                hgrow = Priority.ALWAYS
            }
        }

        vbox {
            listview(selectedCircles)

            padding = Insets(10.0)
        }
    }
	
	

The lefthand side of the screen is an AnchorPane containing the workArea Pane. The righthand side of the screen is a VBox containing a ListView. In the workArea Pane there is a factory function createCircle. Called four times, createCircle permanently adds four circles to workArea. lassoRect is also added to the workArea, although it will remain invisible until needed in a drag operation. Several EventFilters are added to workArea (no EventFilters or EventHandlers are associated with the individual Circle Nodes).

The ListView is based on the selectedCircles FXCollections ObservableList. Any change made later to selectedCircles will result in an update to the ListView. In TornadoFX, passing the selectedCircles ObservableList into the listview() builder binds the UI control (the ListView) to the data structure. That means that the changes to selectedCircles presented later will automatically update the ListView. This demonstrates how you might transition from the graphical world of shapes to the data-oriented world of a model.

Three EventListeners are registered on the workArea: press(), lasso(), and stopLasso(). Notice that the EventListeners are not registered on the shapes themselves. For an application like this, I find it easier to handle logic associated with the MouseEvents at the container level rather than at the individual Node level. The dragging operation involves multiple objects including the container so I find it cleaner to write code where all the objects are available.

Operation

The press() method is activated when the user generates a MOUSE_PRESSED on workArea. This method operates in two modes, single-select and multi-select. The modes are distinguished by the presense of a modifier, Control or Shift. If neither modifier is pressed, then the selection is cleared. This is followed with logic which will toggle the selected state of a Circle.

    private fun press(evt : MouseEvent) {

        if( !evt.isControlDown && !evt.isShiftDown ) {

            circles
                    .forEach {
                        if( it.hasClass(SelectingStyles.selected) ) {
                            it.removeClass(SelectingStyles.selected)
                        }
                    }

            selectedCircles.clear()
        }

        circles
                .filter {
                    val mousePt : Point2D = it.sceneToLocal( evt.sceneX, evt.sceneY )
                    it.contains(mousePt)
                }
                .firstOrNull()
                .apply {
                    if( this != null ) {
                        if( this.hasClass( SelectingStyles.selected ) ) {
                            this.removeClass(SelectingStyles.selected)
                            selectedCircles.remove(this.properties["circleColor"] as String)
                        } else {
                            selectCircle(this)
                        }
                    }
                }
    }

The lasso capability is implemented with a pair of functions, lasso() and stopLasso(). These methods manipulate the tracking shape lassoRect. With lassoRect, the user drags a Rectangle on the workArea. lasso() moves and resizes this Rectangle as the user generates MOUSE_DRAGGED events. Once the user releases the mouse, stopLasso() is invoked and the area under lassoRect is examined for any enclosed circles. These enclosed circles will be the selection.

    private fun lasso(evt : MouseEvent) {
        if( !initDrag ) {
            lassoRect.isVisible = true
            dragStart = workArea.sceneToLocal(evt.sceneX, evt.sceneY)
            if( dragStart != null ) {
                lassoRect.relocate(dragStart!!.x, dragStart!!.y)
            }
            initDrag = true
        }

        // find width and height; negatives ok?
        val currPos = workArea.sceneToLocal(evt.sceneX, evt.sceneY)
        if( dragStart != null ) {

            val w = currPos.x - dragStart!!.x
            val y = currPos.y - dragStart!!.y

            lassoRect.width = abs(w)
            lassoRect.height = abs(y)

            if( w < 0 || y < 0 ) { // dragging left or up
                lassoRect.relocate(currPos.x, currPos.y)
            }
        }
    }
	
	

The stopLasso() method will be invoked for any MOUSE_RELEASED event in the workArea. However, its logic will only be applied if the dragStart variable was set by a previous MOUSE_DRAGGED event.

    private fun stopLasso(evt : MouseEvent) {
        if( initDrag ) {  // in drag operation

            lassoRect.isVisible = false

            val lassoBounds = lassoRect.boundsInParent

            circles
                .filter {
                    lassoBounds.contains(it.boundsInParent)
                }
                .forEach {
                    selectCircle(it)
                }
        }

        initDrag = false
        dragStart = null
    }
	
	

selectCircle is a refactor of some code used in two places.

    private fun selectCircle(circ : Circle) {
        circ.addClass(SelectingStyles.selected)
        selectedCircles.add(circ.properties["circleColor"] as String)
    }

The App subclass and styles can be found in the source .zip referenced below.

Wrap Up

This article showed how to select graphical items on a workArea and how those selected items can be translated into a model component. For this requirement, I opted to handles the events at the workArea-level rather than at the individual Nodes. Although the individual shape Nodes can have EventFilters and EventHandlers attached, the selection algorithm spans components so I felt it best to handle these at a level where all the Nodes are available.

Resources

The source code presented in this article series is a Gradle project found in the zip file below. The SelectingApp source is paired with another example, DraggingApp.

To run the demo, create an Application configuration in your IDE that will run the SelectingApp class. In IntellIJ,

  1. Go to Run > Edit Configurations
  2. Select Application and press +
  3. Name the configuration
  4. Select the SelectingApp.kt file.

Headshot of Carl Walker

By Carl Walker

President and Principal Consultant of Bekwam, Inc