Skip to content

Commit

Permalink
Merge pull request Hexworks#373 from nanodeath/scrollable-controls
Browse files Browse the repository at this point in the history
Vertical Scrollable List Fragment
  • Loading branch information
adam-arold authored Apr 27, 2021
2 parents 8441af2 + d992f8a commit adcda81
Show file tree
Hide file tree
Showing 10 changed files with 503 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 = ""
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.hexworks.zircon.internal.renderer

import org.hexworks.cobalt.databinding.api.extension.toProperty
import org.hexworks.cobalt.databinding.api.property.Property
import org.hexworks.cobalt.databinding.api.value.ObservableValue
import org.hexworks.zircon.api.behavior.Clearable
import org.hexworks.zircon.api.component.ComponentContainer
import org.hexworks.zircon.api.data.Size
import org.hexworks.zircon.api.graphics.TileGraphics
import org.hexworks.zircon.api.grid.TileGrid
import org.hexworks.zircon.api.resource.TilesetResource
import org.hexworks.zircon.api.uievent.UIEvent
import org.hexworks.zircon.api.uievent.UIEventResponse
import org.hexworks.zircon.api.view.base.BaseView
import org.hexworks.zircon.internal.behavior.RenderableContainer
import org.hexworks.zircon.internal.config.RuntimeConfig
import org.hexworks.zircon.internal.graphics.FastTileGraphics
import org.hexworks.zircon.internal.grid.ThreadSafeTileGrid
import org.hexworks.zircon.internal.uievent.UIEventDispatcher

/**
* This is a simple test renderer that draws things back into the provided [tileGraphics]. After instantiation,
* you should call [withComponentContainer] to add components and fragments, and then call [render] to see the result.
* You can also [dispatch] events to interact with it.
*
* @sample org.hexworks.zircon.internal.renderer.TestRendererTest.tinyExample
*/
class TestRenderer(
private val tileGraphics: TileGraphics,
tileset: TilesetResource = RuntimeConfig.config.defaultTileset,
gridSize: Size = Size.defaultGridSize()
) : UIEventDispatcher, Renderer, Clearable {
private val tileGrid: TileGrid = ThreadSafeTileGrid(tileset, gridSize)
private val mainView = object : BaseView(tileGrid) {}
private val closedValueProperty: Property<Boolean> = false.toProperty()
override val closedValue: ObservableValue<Boolean> get() = closedValueProperty

init {
mainView.dock()
}

fun withComponentContainer(cb: ComponentContainer.() -> Unit) {
with(mainView.screen, cb)
}

override fun create() {
}

override fun clear() {
tileGraphics.clear()
}

override fun render() {
(tileGrid as RenderableContainer).renderables.forEach { renderable ->
if (!renderable.isHidden) {
val graphics = FastTileGraphics(
initialSize = renderable.size,
initialTileset = renderable.tileset,
initialTiles = mapOf()
)
renderable.render(graphics)
graphics.contents().forEach { (pos, tile) ->
tileGraphics.draw(tile, pos + renderable.position)
}
}
}
}

override fun dispatch(event: UIEvent): UIEventResponse = (mainView.screen as UIEventDispatcher).dispatch(event)

override fun close() {
if (!closedValueProperty.value) {
tileGrid.close()
closedValueProperty.value = true
}
}

override val isClosed: ObservableValue<Boolean> get() = closedValue
}
Loading

0 comments on commit adcda81

Please sign in to comment.