Skip to content

Commit

Permalink
Introduce VerticalScrollableList.
Browse files Browse the repository at this point in the history
Closes Hexworks#372.
  • Loading branch information
nanodeath committed Apr 27, 2021
1 parent 8cd269a commit 282f59d
Show file tree
Hide file tree
Showing 8 changed files with 379 additions and 114 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,24 @@ class VerticalScrollBarBuilder(
private var maxValue: Int = 100
) : BaseComponentBuilder<ScrollBar, VerticalScrollBarBuilder>(VerticalScrollBarRenderer()) {

private var itemsShownAtOnce: Int? = null

fun withNumberOfScrollableItems(items: Int) = also {
require(items > 0) { "Number of items must be greater than 0." }
this.maxValue = items
}

fun withItemsShownAtOnce(count: Int) = also {
require(count > 0) { "Count must be greater than 0." }
this.itemsShownAtOnce = count
}

override fun build(): ScrollBar = DefaultVerticalScrollBar(
componentMetadata = createMetadata(),
renderingStrategy = createRenderingStrategy(),
minValue = minValue,
maxValue = maxValue,
itemsShownAtOnce = size.height,
itemsShownAtOnce = itemsShownAtOnce ?: size.height,
numberOfSteps = size.height,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.hexworks.zircon.api.fragment

import org.hexworks.zircon.api.Beta
import org.hexworks.zircon.api.component.Fragment

@Beta
interface ScrollableList<T> : Fragment {
val items: List<T>
fun scrollTo(idx: Int)
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,7 @@ abstract class BaseScrollBar(
}

private fun computeCurrentStep(newValue: Int) {
val actualValue = when {
newValue > maxValue -> maxValue
newValue < minValue -> minValue
else -> newValue
}
val actualValue = newValue.coerceIn(minValue..maxValue)
val actualStep = actualValue.toDouble() / valuePerStep
val roundedStep = truncate(actualStep)
currentStep = roundedStep.toInt()
Expand All @@ -84,6 +80,9 @@ abstract class BaseScrollBar(
override fun incrementStep() {
if (currentStep + barSizeInSteps < numberOfSteps) {
computeValueToClosestOfStep(currentStep + 1)
} else {
// Try to increase by partial step, so we can always get to the last partial page of items
incrementValues()
}
}

Expand Down Expand Up @@ -168,6 +167,20 @@ abstract class BaseScrollBar(
} else Pass
}

override fun mouseWheelRotatedUp(event: MouseEvent, phase: UIEventPhase): UIEventResponse {
if (phase != UIEventPhase.TARGET) return Pass
val originalValue = currentValue
decrementStep()
return if (currentValue != originalValue) Processed else Pass
}

override fun mouseWheelRotatedDown(event: MouseEvent, phase: UIEventPhase): UIEventResponse {
if (phase != UIEventPhase.TARGET) return Pass
val originalValue = currentValue
incrementStep()
return if (currentValue != originalValue) Processed else Pass
}

abstract override fun keyPressed(event: KeyboardEvent, phase: UIEventPhase): UIEventResponse

override fun activated() = whenEnabledRespondWith {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ class DefaultVerticalScrollBar(
if (phase == UIEventPhase.TARGET) {
when (event.code) {
KeyCode.UP -> {
incrementValues()
decrementValues()
Processed
}
KeyCode.DOWN -> {
decrementValues()
incrementValues()
Processed
}
KeyCode.RIGHT -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import org.hexworks.zircon.api.data.Position
import org.hexworks.zircon.api.data.Tile
import org.hexworks.zircon.api.graphics.TileGraphics

@Suppress("DuplicatedCode")
class VerticalScrollBarRenderer : ComponentRenderer<ScrollBar> {
open class VerticalScrollBarRenderer internal constructor() : ComponentRenderer<ScrollBar> {
open val aboveBarCharacter: Char = ' '
open val belowBarCharacter: Char = ' '
open val barCharacter: Char = ' '

override fun render(tileGraphics: TileGraphics, context: ComponentRenderContext<ScrollBar>) {
final override fun render(tileGraphics: TileGraphics, context: ComponentRenderContext<ScrollBar>) {
val defaultStyleSet = context.componentStyle.fetchStyleFor(ComponentState.DEFAULT)
val invertedDefaultStyleSet = defaultStyleSet
.withBackgroundColor(defaultStyleSet.foregroundColor)
Expand All @@ -24,18 +26,18 @@ class VerticalScrollBarRenderer : ComponentRenderer<ScrollBar> {

tileGraphics.applyStyle(context.currentStyle)

(0..totalScrollBarHeight).forEach { idx ->
(0 until totalScrollBarHeight).forEach { idx ->
when {
idx < lowBarPosition -> tileGraphics.draw(
Tile.createCharacterTile(' ', disabledStyleSet),
Tile.createCharacterTile(aboveBarCharacter, disabledStyleSet),
Position.create(0, idx)
)
idx > highBarPosition -> tileGraphics.draw(
Tile.createCharacterTile(' ', disabledStyleSet),
Tile.createCharacterTile(belowBarCharacter, disabledStyleSet),
Position.create(0, idx)
)
else -> tileGraphics.draw(
Tile.createCharacterTile(' ', invertedDefaultStyleSet),
Tile.createCharacterTile(barCharacter, invertedDefaultStyleSet),
Position.create(0, idx)
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package org.hexworks.zircon.internal.fragment.impl

import org.hexworks.zircon.api.Components
import org.hexworks.zircon.api.component.Label
import org.hexworks.zircon.api.component.ScrollBar
import org.hexworks.zircon.api.component.renderer.ComponentRenderer
import org.hexworks.zircon.api.data.Position
import org.hexworks.zircon.api.data.Size
import org.hexworks.zircon.api.fragment.ScrollableList
import org.hexworks.zircon.api.graphics.Symbols
import org.hexworks.zircon.api.uievent.ComponentEventType

/**
* This creates a vertically-scrolling list. You provide a list of [items] and a subset of them are rendered with
* a scrollbar on the right side.
*
* ### Navigation
* * To scroll by **single row**, you can click or activate the top/bottom arrows. Arrow keys while the bar is focused also
* works.
* * To scroll by **step** (small jumps), you can click on the empty parts above or below the bar, or use the mouse wheel.
* * You can also click and drag the bar itself.
*
* ### Limitations
* * [items] is immutable. You can't change the list after its created.
* * [items] aren't focusable. You can click on them, but you can't tab to them.
* * Each [item][items] can't span multiple lines. It will be clipped if it's too long.
* * Even if [items] fully fits in [size], a scrollbar will still be displayed.
*/
class VerticalScrollableList<T>(
private val size: Size,
position: Position,
override val items: List<T>,
/** Handler for when an item in the list is activated. */
private val onItemActivated: (item: T, idx: Int) -> Unit = { _, _ -> },
/** Transform items in [items] into displayable strings. */
private val renderItem: (T) -> String = { it.toString() },
/** If set, use this instead of the default [ComponentRenderer] for the [ScrollBar] created internally. */
private val scrollbarRenderer: ComponentRenderer<ScrollBar>? = null
) : ScrollableList<T> {
/** Reusable list of labels we display in the main scroll panel. */
private val labels = mutableListOf<Label>()

/** Index in [items] of the top item we're showing in the main scroll panel. */
private var topItemIdx: Int = 0

override val root = Components.hbox()
.withSize(size)
.withPosition(position)
.withSpacing(0)
.build()

private val scrollPanel = Components.vbox()
.withSize(size.withRelativeWidth(-1))
.withDecorations()
.withSpacing(0)
.build()

private val scrollBarVbox = Components.vbox()
.withSize(size.withWidth(1))
.withDecorations()
.withSpacing(0)
.build()

private val actualScrollbar: ScrollBar = Components.verticalScrollbar()
.withSize(1, size.height - 2)
.withItemsShownAtOnce(size.height)
.withNumberOfScrollableItems(items.size)
.withDecorations()
.also { builder ->
scrollbarRenderer?.let { builder.withComponentRenderer(it) }
}
.build()

private val decrementButton = Components.button()
.withText("${Symbols.TRIANGLE_UP_POINTING_BLACK}")
.withSize(1, 1)
.withDecorations()
.build()

private val incrementButton = Components.button()
.withText("${Symbols.TRIANGLE_DOWN_POINTING_BLACK}")
.withSize(1, 1)
.withDecorations()
.build()

init {
root.addComponents(scrollPanel, scrollBarVbox)

decrementButton.processComponentEvents(ComponentEventType.ACTIVATED) {
actualScrollbar.decrementValues()
}
incrementButton.processComponentEvents(ComponentEventType.ACTIVATED) {
actualScrollbar.incrementValues()
}

actualScrollbar.onValueChange {
scrollTo(it.newValue)
}

scrollBarVbox.addComponents(decrementButton, actualScrollbar, incrementButton)

displayListFromIndex()
}

override fun scrollTo(idx: Int) {
topItemIdx = idx
displayListFromIndex()
}

private fun displayListFromIndex() {
val maxIdx = when {
topItemIdx + size.height < items.size -> topItemIdx + size.height
else -> items.size
}
for (idx in topItemIdx until maxIdx) {
val labelIdx = idx - topItemIdx
// Generate and add labels until we have enough for the current entry
while (labelIdx > labels.lastIndex) {
labels.add(Components.label()
.withDecorations()
.withSize(scrollPanel.contentSize.withHeight(1))
.build()
.also { label ->
scrollPanel.addComponent(label)
label.onActivated {
onItemActivated(items[topItemIdx + labelIdx], topItemIdx + labelIdx)
}
}
)
}
labels[labelIdx].text = renderItem(items[idx])
}
// Clear any remaining labels, just in case
for (labelIdx in (maxIdx - topItemIdx) until labels.size) {
labels[labelIdx].text = ""
}
}
}
Loading

0 comments on commit 282f59d

Please sign in to comment.