Skip to content

Primitives

rivo edited this page Dec 4, 2020 · 10 revisions

How to Implement Your Own Primitive

In some rare cases, you may need functionality that tview does not offer. If it's something that you believe may be needed by others as well, consider opening an issue with a feature request. (Definitely do that before sending me pull requests.) If it's a reasonable request, it's likely that I will add it to tview.

If your request is very specific to your application, however, and existing primitives such as TextView are not sufficient to achieve what you want to do, you may need to write your own Primitive.

Alternatives

Before you start writing your own Primitive, you should know that there are alternatives. It is likely that you only need direct access to tcell's Screen during certain points of a primitive's lifecycle. This can be achieved without having to fully implement the Primitive interface.

Hooking Into a Primitive's Draw() Function

All of tview's primitives inherit from Box. And Box provides a function SetDrawFunc() which lets you install a callback function that is called after the Box has been drawn.

SetDrawFunc() gives you a tcell.Screen object and the coordinates of the Box. In the following example, we want our box to have a horizontal line and some text in its center:

tview.NewBox().
  SetBorder(true).
  SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) {
    // Draw a horizontal line across the middle of the box.
    centerY := y + height/2
    for cx := x + 1; cx < x+width-1; cx++ {
      screen.SetContent(cx, centerY, tview.BoxDrawingsLightHorizontal, nil, tcell.StyleDefault.Foreground(tcell.ColorWhite))
    }

    // Write some text along the horizontal line.
    tview.Print(screen, " Center Line ", x+1, centerY, width-2, tview.AlignCenter, tcell.ColorYellow)

    // Space for other content.
    return x + 1, centerY + 1, width - 2, height - (centerY + 1 - y)
  })

This also works for other primitives, and that's where the return values come into play. They dictate where the other content of the primitive will be placed. Here is the same example but for a TextView:

tview.NewTextView().
  SetText("This is the text view's content").
  SetTextAlign(tview.AlignCenter)
textView.SetBorder(true).
  SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) {
    // Draw a horizontal line across the middle of the box.
    centerY := y + height/2
    for cx := x + 1; cx < x+width-1; cx++ {
      screen.SetContent(cx, centerY, tview.GraphicsHoriBar, nil, tcell.StyleDefault.Foreground(tcell.ColorWhite))
    }

    // Write som text along the horizontal line.
    tview.Print(screen, " Center Line ", x+1, centerY, width-2, tview.AlignCenter, tcell.ColorYellow)

    // Space for other content.
    return x + 1, centerY + 1, width - 2, height - (centerY + 1 - y)
  })

Hooking Into the Application's Draw() Function

If what you need to do affects all primitives, you can also get access to tcell.Screen when the entire Application is redrawn. There are two functions for this:

  • SetBeforeDrawFunc(): The provided callback function is invoked just before the Application draws its root Primitive. This allows you to draw onto the screen "underneath" any primitives.
  • SetAfterDrawFunc(): The provided callback function is invoked after the Application has drawn its root Primitive. This is useful if you need to draw something on top of all primitives.

Hooking Into Key Events

In addition to drawing, you can also intercept all key events. Again, this can be done on the Box level or on the Application level:

  • Application.SetInputCapture(): The provided callback function is invoked when a key is pressed. Whatever key event you return will be passed on to the default Application handling.
  • Box.SetInputCapture(): Same as Application.SetInputCapture() but on a primitive level. The callback function is invoked when the primitive has focus and a key is pressed.

For example, if you want to cause the application to shut down upon Ctrl-Q in addition to tview's default Ctrl-C, you can achieve that in the following way:

app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
  if event.Key() == tcell.KeyCtrlQ {
    app.Stop()
  }
  return event
})

You can also hook into mouse events. It works similarly to hooking into key events. Please check the source code documentation.

Writing Primitives

If none of this is enough to achieve your goals, you can write your own Primitive. Please note that the Primitive interface and some of its handling may be subject to changes.

One could say that all you need to do is implement the Primitive interface. And this is true. But your life will be much easier if you subclass from the Box primitive, as all other primitives do, because Box already provides default implementations for many of Primitive's functions.

Let's say we want to implement a simple radio buttons primitive. The definition could look like this:

type RadioButtons struct {
	*tview.Box
	options       []string
	currentOption int
}

func NewRadioButtons(options []string) *RadioButtons {
	return &RadioButtons{
		Box:     tview.NewBox(),
		options: options,
	}
}

Because we subclass from the Box primitive, we automatically inherit all of its functions such as SetBorder() and SetTitle().

Boxes are empty per default so we add our own Draw() function to draw the radio button options:

func (r *RadioButtons) Draw(screen tcell.Screen) {
	r.Box.DrawForSubclass(screen, r)
	x, y, width, height := r.GetInnerRect()

	for index, option := range r.options {
		if index >= height {
			break
		}
		radioButton := "\u25ef" // Unchecked.
		if index == r.currentOption {
			radioButton = "\u25c9" // Checked.
		}
		line := fmt.Sprintf(`%s[white]  %s`, radioButton, option)
		tview.Print(screen, line, x, y+index, width, tview.AlignLeft, tcell.ColorYellow)
	}
}

At first, we call the DrawForSubclass() function of Box. In theory, you could also call Box.Draw() here but then Box would not be able to call your HasFocus() function to draw the correct box frame — so we call DrawForSubclass() instead which is intended to be used only in this specific situation.

This will clear the space for us and possibly draw the border and title. We then determine the area that we need to draw into by calling GetInnerRect(). After that, we draw the options, consisting of a radio button (checked or unchecked) and the option text. We need to ensure that we don't draw outside the allowed space so we break out of the loop when we reach the maximum height. Horizontally, the tview.Print() function will take care of that.

The tview.Print() function is quite powerful and it is used throughout the entire tview package. It takes a screen coordinate and a maximum width. Text will not be written outside that box. In addition, you can align the text to the left, right, or center, and give it a color. It also uses color tags (described here) so you can give different parts of the text different colors.

The result may look like this:

You will also want to let the user select a radio button by using the up and down arrow keys. This is done by implementing the InputHandler() function. If this function returns nil (the Box primitive's default), your primitive does not process any keyboard input and will therefore not receive focus. If you want it to process keyboard input, you return a function which is called on each key event:

func (r *RadioButtons) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
	return r.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
		switch event.Key() {
		case tcell.KeyUp:
			r.currentOption--
			if r.currentOption < 0 {
				r.currentOption = 0
			}
		case tcell.KeyDown:
			r.currentOption++
			if r.currentOption >= len(r.options) {
				r.currentOption = len(r.options) - 1
			}
		}
	})
}

We check the key event and change the primitive's current option based on whether the up arrow or the down arrow was pressed. You will notice that we call WrapInputHandler(). This is not strictly necessary but it will allow users of the primitive to use the Box.SetInputCapture() function.

A setFocus function is also provided which allows you to pass the focus onto another primitive. This is usually only needed when your primitive is composed of other primitives. For example, the DropDown primitive internally uses a List primitive for the selectable elements. Once the user presses a key, that list pops out and receives focus. The next section describes how to deal with primitive compositions.

Additionally, mouse events should be handled:

func (r *RadioButtons) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) {
	return r.WrapMouseHandler(func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) {
		x, y := event.Position()
		_, rectY, _, _ := r.GetInnerRect()
		if !r.InRect(x, y) {
			return false, nil
		}

		if action == tview.MouseLeftClick {
			setFocus(r)
			index := y - rectY
			if index >= 0 && index < len(r.options) {
				r.currentOption = index
				consumed = true
			}
		}

		return
	})
}

If the mouse click was outside the primitive's box, we can ignore it. A left click should set the focus to this primitive and select the clicked option. We set consumed to true to let the application know that we handled the mouse click. This will result in a redraw.

By the way, you can find the radio button code in the demos/primitive directory.

Primitives Composed of Other Primitives

Some primitives are composed of other primitives. For example, the Flex, Grid, and Form primitives can all contain other primitives. If your primitive does not fall into this category, you can ignore this section.

If you implement a primitive composed of other primitives, you can simply call their SetRect() function to position them and their Draw() function in your own Draw() function.

Keyboard input is slightly more complicated in this case, however, because only one primitive can have focus at any time. First of all, you will need to decide which of your "child primitives" will receive focus when you receive focus. This is done by implementing the Focus() function as follows:

func (p *MyPrimitive) Focus(delegate func(p Primitive)) {
	delegate(p.childPrimitives[p.currentChild])
}

The Focus() function receives a "delegate" function which can be used to pass on the focus to one of your child elements. Alternatively, if your primitive needs focus itself in some cases, the function could look like this (the default, implemented in Box, is to keep the focus to yourself):

func (p *MyPrimitive) Focus(delegate func(p Primitive)) {
	if d.childPrimitive != nil {
		delegate(d.childPrimitive)
	} else {
		p.Box.Focus(delegate)
	}
}

Because your primitive can also be part of another primitive, we need to know if your primitive or one of its child primitives currently has focus. You therefore need to implement the HasFocus() function:

func (p *MyPrimitive) HasFocus() bool {
	if p.childPrimitive != nil {
		return p.childPrimitive.HasFocus()
	} else {
		p.Box.HasFocus()
	}
}

Key events as well as mouse events are passed down the hierarchy. So you will need to implement the handlers accordingly. For example:

func (p *MyPrimitive) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
	return p.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
		if !p.hasFocus {
			// Pass event on to child primitive.
			if p.childPrimitive != nil && p.childPrimitive.HasFocus() {
				if handler := p.childPrimitive.InputHandler(); handler != nil {
					handler(event, setFocus)
				}
			}
			return
		}
		// ...handle key events not forwarded to the child primitive...
	}
}

func (p *MyPrimitive) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture Primitive) {
	return p.WrapMouseHandler(func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture Primitive) {
		if !p.InRect(event.Position()) {
			return false, nil
		}

		// Pass mouse events down.
		if p.childPrimitive != nil {
			consumed, capture = p.childPrimitive.MouseHandler()(action, event, setFocus)
			if consumed {
				return
			}
		}

		// ...handle mouse events not directed to the child primitive...
		return true, nil
	})
}