Skip to content

Instantly share code, notes, and snippets.

@dant3
Created July 14, 2015 21:07
Show Gist options
  • Save dant3/4a4249a886e4ec5625e6 to your computer and use it in GitHub Desktop.
Save dant3/4a4249a886e4ec5625e6 to your computer and use it in GitHub Desktop.
Lanterna 2 ListView & ListAdapter - a very simple version
trait ListAdapter[+T] extends Reactive {
def isEmpty:Boolean = size <= 0
def indexes:Range = 0 to maxIndex step 1
def maxIndex:Int = size - 1
def apply(index: Int):T = item(index)
def size:Int
def item(index: Int):T
def createItemString(index: Int): String
}
import com.googlecode.lanterna.gui.Interactable.{FocusChangeDirection, Result}
import com.googlecode.lanterna.gui.Theme.Category
import com.googlecode.lanterna.gui.component.AbstractInteractableComponent
import com.googlecode.lanterna.gui.{Interactable, TextGraphics, Theme}
import com.googlecode.lanterna.input.Key
import com.googlecode.lanterna.input.Key.Kind._
import com.googlecode.lanterna.terminal.{ACS, TerminalPosition, TerminalSize}
import scala.annotation.switch
class ListView[T](val adapter:ListAdapter[T]) extends AbstractInteractableComponent {
private var selection: Int = -1
private var scrollTopIndex: Int = 0
private var pageSize: Int = 0
private var itemPressListener:(T) => Any = null
adapter.onChanged { () => invalidate() }
def onItemPressed(f:T => Any):ListView[T] = {
itemPressListener = f
this
}
override protected def calculatePreferredSize: TerminalSize = {
val size = adapter.size
val itemStringSizes = for {i <- adapter.indexes} yield adapter.createItemString(i).size
val maxItemSize = if (itemStringSizes.isEmpty) 0 else itemStringSizes.max
val maxWidth = math.max(maxItemSize, 5)
new TerminalSize(maxWidth + 1, size)
}
def selectedIndex_=(index: Int) {
selection = index
invalidate()
}
def selectedIndex = selection
override def isScrollable: Boolean = true
override def repaint(graphics: TextGraphics) = Renderer.repaint(graphics)
protected def getHotSpotPositionOnLine(selectedIndex: Int): Int = 0
protected override def afterEnteredFocus(direction: Interactable.FocusChangeDirection) = if (!adapter.isEmpty) {
(direction: @switch) match {
case FocusChangeDirection.DOWN => selectedIndex = 0
case FocusChangeDirection.UP => selectedIndex = adapter.size - 1
case _ =>
}
}
override def keyboardInteraction(key: Key): Interactable.Result = {
val keyHandlingResult = InputHandler.handleKey(key)
invalidate()
keyHandlingResult
}
object Renderer {
def repaint(graphics: TextGraphics) {
//update the page size, used for page up and page down keys
pageSize = graphics.getHeight
Renderer.updateScroll(graphics)
Renderer.renderItems(graphics)
Renderer.drawScrollBar(graphics)
Renderer.updateHotspot(graphics)
}
def updateScroll(graphics: TextGraphics) = {
if (selectedIndex != -1) {
if (selectedIndex < scrollTopIndex)
scrollTopIndex = selectedIndex
else if (selectedIndex >= graphics.getHeight + scrollTopIndex)
scrollTopIndex = selectedIndex - graphics.getHeight + 1
}
//Do we need to recalculate the scroll position?
//This code would be triggered by resizing the window when the scroll
//position is at the bottom
val itemsCount: Int = adapter.size
if (itemsCount > graphics.getHeight && itemsCount - scrollTopIndex < graphics.getHeight) {
scrollTopIndex = itemsCount - graphics.getHeight
}
graphics.applyTheme(getListItemThemeDefinition(graphics.getTheme))
graphics.fillArea(' ')
}
def renderItems(graphics: TextGraphics) = {
val adapterIndexes = scrollTopIndex to math.min(adapter.maxIndex, graphics.getHeight + scrollTopIndex)
for (index <- adapterIndexes) {
if (index == selectedIndex && hasFocus)
graphics.applyTheme(getSelectedListItemThemeDefinition(graphics.getTheme))
else
graphics.applyTheme(getListItemThemeDefinition(graphics.getTheme))
Renderer.renderItem(graphics, 0, index - scrollTopIndex, index)
}
}
def drawScrollBar(graphics: TextGraphics) = {
val itemsCount = adapter.size
if (itemsCount > graphics.getHeight) {
graphics.applyTheme(Theme.Category.DIALOG_AREA)
graphics.drawString(graphics.getWidth - 1, 0, ACS.ARROW_UP + "")
graphics.applyTheme(Theme.Category.DIALOG_AREA)
for (row <- 1 to (graphics.getHeight - 1)) {
graphics.drawString(graphics.getWidth - 1, row, ACS.BLOCK_MIDDLE + "")
}
graphics.applyTheme(Theme.Category.DIALOG_AREA)
graphics.drawString(graphics.getWidth - 1, graphics.getHeight - 1, ACS.ARROW_DOWN + "")
val scrollableSize: Int = itemsCount - graphics.getHeight
val position: Double = scrollTopIndex.asInstanceOf[Double] / scrollableSize.asInstanceOf[Double]
val tickPosition: Int = ((graphics.getHeight.asInstanceOf[Double] - 3.0) * position).asInstanceOf[Int]
graphics.applyTheme(Theme.Category.SHADOW)
graphics.drawString(graphics.getWidth - 1, 1 + tickPosition, " ")
}
}
def updateHotspot(graphics: TextGraphics) = {
if (selectedIndex == -1 || adapter.isEmpty) {
setHotspot(new TerminalPosition(0, 0))
} else {
val hotspotPosition = new TerminalPosition(getHotSpotPositionOnLine(selectedIndex), selectedIndex - scrollTopIndex)
setHotspot(graphics.translateToGlobalCoordinates(hotspotPosition))
}
}
/**
* Draws an item in the ListBox at specific coordinates. If you override this method,
* please note that the x and y positions have been pre-calculated for you and you should use
* the values supplied instead of trying to figure out the position on your own based on the
* index of the item.
* @param graphics TextGraphics object to use when drawing the item
* @param x X-coordinate on the terminal of the item, pre-adjusted for scrolling
* @param y Y-coordinate on the terminal of the item, pre-adjusted for scrolling
* @param index Index of the item that is to be drawn
*/
protected def renderItem(graphics: TextGraphics, x: Int, y: Int, index: Int) {
def trimmedByGraphics(value: String) = if (value.length > graphics.getWidth) {
value.substring(0, graphics.getWidth)
} else {
value
}
graphics.drawString(x, y, trimmedByGraphics(adapter.createItemString(index)))
}
protected def getSelectedListItemThemeDefinition(theme: Theme): Theme.Definition = {
theme.getDefinition(Theme.Category.LIST_ITEM_SELECTED)
}
protected def getListItemThemeDefinition(theme: Theme): Theme.Definition = {
theme.getDefinition(Category.LIST_ITEM)
}
}
object InputHandler {
def handleKey(key: Key) = (key.getKind: @switch) match {
case Tab | ArrowRight => Result.NEXT_INTERACTABLE_RIGHT
case ReverseTab | ArrowLeft => Result.PREVIOUS_INTERACTABLE_LEFT
case ArrowDown => onArrowDown
case ArrowUp => onArrowUp
case Home => onHome
case End => onEnd
case PageUp => onPageUp
case PageDown => onPageDown
case Enter => onEnter
case _ => Result.EVENT_NOT_HANDLED
}
// Enter
def onEnter = if (adapter.indexes.contains(selectedIndex) && itemPressListener != null) {
itemPressListener(adapter.item(selectedIndex))
Result.EVENT_HANDLED
} else {
Result.EVENT_NOT_HANDLED
}
// ▼
def onArrowDown = if (adapter.isEmpty || selectedIndex == adapter.size - 1) {
Result.NEXT_INTERACTABLE_DOWN
} else {
selectedIndex += 1
Result.EVENT_HANDLED
}
// ▲
def onArrowUp = if (adapter.isEmpty || selectedIndex == 0) {
Result.PREVIOUS_INTERACTABLE_UP
} else {
selectedIndex -= 1
if (selectedIndex - scrollTopIndex < 0) {
scrollTopIndex = selectedIndex
}
Result.EVENT_HANDLED
}
// HOME
def onHome = {
selectedIndex = 0
Result.EVENT_HANDLED
}
// END
def onEnd = {
selectedIndex = adapter.maxIndex
Result.EVENT_HANDLED
}
// PAGE UP
def onPageUp = {
selectedIndex -= pageSize
if (selectedIndex < 0) {
selectedIndex = 0
}
Result.EVENT_HANDLED
}
// PAGE DOWN
def onPageDown = {
selectedIndex += pageSize
if (selectedIndex > adapter.maxIndex) {
selectedIndex = adapter.maxIndex
}
Result.EVENT_HANDLED
}
}
}
trait Reactive {
private var changeListeners:Seq[() => Any] = Seq()
def onChanged(f:() => Any) = {
changeListeners = changeListeners :+ f
}
protected def notifyChanged() = for (changeListener <- changeListeners) {
changeListener()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment