diff --git a/modules/core/src/main/scala/InfiniscrollableState.scala b/modules/core/src/main/scala/InfiniscrollableState.scala index 489667d..293bab6 100644 --- a/modules/core/src/main/scala/InfiniscrollableState.scala +++ b/modules/core/src/main/scala/InfiniscrollableState.scala @@ -16,28 +16,35 @@ package cue4s -private[cue4s] trait InfiniscrollableState[A <: InfiniscrollableState[A]]: - self: A => - - def showing: Option[(List[Int], Int)] - def windowStart: Int - def windowSize: Int +private[cue4s] case class InfiniscrollableState( + showing: Option[(List[Int], Int)], + windowStart: Int, + windowSize: Int +): + def changeSelection(move: Int) = + showing match + case None => this // do nothing, no alternatives are showing + case a @ Some((filtered, showing)) => + val position = filtered.indexOf(showing) - protected def changeSelection(move: Int): A + val newSelected = + (position + move).max(0).min(filtered.length - 1) - // implement as copy(windowStart = computeWindowStartAfterSearch) - // use it after any filtering operation - protected def resetWindow(): A + copy(showing = a.map(_ => (filtered, filtered(newSelected)))) - protected def scrolledUpWindowStart: Int = (windowStart - 1).max(0) + def resetWindow() = + showing match + case None => this + case Some((filtered, selected)) => + val position = filtered.indexOf(selected) + val newWindowStart = + (position - windowSize / 2).max(0).min(filtered.length - windowSize) - // implement as copy(windowStart = scrolledUpWindowStart) - protected def scrollUp: A + copy(windowStart = newWindowStart) - protected def scrolledDownWindowStart: Int = windowStart + 1 + def scrollUp = copy(windowStart = (windowStart - 1).max(0)) - // implement as copy(windowStart = scrolledDownWindowStart) - protected def scrollDown: A + def scrollDown = copy(windowStart = windowStart + 1) def atTopScrollingPoint(position: Int) = position > windowStart && position == windowStart + (windowSize / 2).min(2) @@ -49,17 +56,7 @@ private[cue4s] trait InfiniscrollableState[A <: InfiniscrollableState[A]]: def visibleEntries(filtered: List[Int]): List[Int] = filtered.slice(windowStart, windowStart + windowSize) - protected def computeWindowStartAfterSearch: Int = - showing match - case None => 0 - case Some((filtered, selected)) => - val position = filtered.indexOf(selected) - val newWindowStart = - (position - windowSize / 2).max(0).min(filtered.length - windowSize) - - newWindowStart - - def up: A = + def up = showing match case None => this case Some((filtered, selected)) => @@ -71,7 +68,7 @@ private[cue4s] trait InfiniscrollableState[A <: InfiniscrollableState[A]]: end match end up - def down: A = + def down = showing match case None => this case Some((filtered, selected)) => diff --git a/modules/core/src/main/scala/InteractiveMultipleChoice.scala b/modules/core/src/main/scala/InteractiveMultipleChoice.scala index 3df446e..3502593 100644 --- a/modules/core/src/main/scala/InteractiveMultipleChoice.scala +++ b/modules/core/src/main/scala/InteractiveMultipleChoice.scala @@ -37,11 +37,13 @@ private[cue4s] class InteractiveMultipleChoice( override def initialState = State( text = "", selected = preSelected.toSet, - showing = Some(altsWithIndex.map(_._2) -> 0), all = altsWithIndex, status = Status.Running, - windowStart = 0, - windowSize = windowSize + display = InfiniscrollableState( + showing = Some(altsWithIndex.map(_._2) -> 0), + windowStart = 0, + windowSize = windowSize + ) ) private lazy val altMapping = altsWithIndex.map(_.swap).toMap @@ -57,11 +59,12 @@ private[cue4s] class InteractiveMultipleChoice( lines += prompt.lab + " > ".cyan + st.text lines += "Tab".bold + " to toggle, " + "Enter".bold + " to submit." - st.showing match + st.display.showing match case None => lines += "no matches...".bold case Some((filtered, selected)) => - st.visibleEntries(filtered) + st.display + .visibleEntries(filtered) .zipWithIndex .foreach: case (id, idx) => @@ -72,9 +75,9 @@ private[cue4s] class InteractiveMultipleChoice( else lines.addOne( if id == selected then s" ‣ $alt".green - else if st.windowStart > 0 && idx == 0 then + else if st.display.windowStart > 0 && idx == 0 then s" ↑ $alt".bold - else if filtered.size > st.windowSize && idx == st.windowSize - 1 && + else if filtered.size > st.display.windowSize && idx == st.display.windowSize - 1 && filtered.indexOf(id) != filtered.size - 1 then s" ↓ $alt".bold else s" $alt" @@ -109,12 +112,12 @@ private[cue4s] class InteractiveMultipleChoice( Next.Continue case Event.Key(KeyEvent.UP) => - stateTransition(_.up) + stateTransition(_.updateDisplay(_.up)) printPrompt() Next.Continue case Event.Key(KeyEvent.DOWN) => - stateTransition(_.down) + stateTransition(_.updateDisplay(_.down)) printPrompt() Next.Continue @@ -166,33 +169,26 @@ private[cue4s] object InteractiveMultipleChoice: case class State( text: String, selected: Set[Int], - showing: Option[(List[Int], Int)], all: List[(String, Int)], status: Status, - windowStart: Int, - windowSize: Int - ) extends InfiniscrollableState[State]: + display: InfiniscrollableState + ): + + def updateDisplay(f: InfiniscrollableState => InfiniscrollableState) = + copy(display = f(display)) def finish = copy(status = Status.Finished(selected)) def cancel = copy(status = Status.Canceled) - override protected def scrollUp = - copy(windowStart = (windowStart - 1).max(0)) - - override protected def scrollDown = copy(windowStart = windowStart + 1) - - override protected def resetWindow() = - copy(windowStart = computeWindowStartAfterSearch) - def addText(t: Char) = - changeText(text + t).resetWindow() + changeText(text + t).updateDisplay(_.resetWindow()) def trimText = - changeText(text.dropRight(1)).resetWindow() + changeText(text.dropRight(1)).updateDisplay(_.resetWindow()) def toggle = - showing match + display.showing match case None => this case Some((_, cursor)) => if selected(cursor) then copy(selected = selected - cursor) @@ -205,12 +201,12 @@ private[cue4s] object InteractiveMultipleChoice: .contains(newText.toLowerCase().trim()) ) if newFiltered.nonEmpty then - showing match + display.showing match case None => val newShowing = newFiltered.headOption.map: (_, id) => newFiltered.map(_._2) -> id - copy(text = newText, showing = newShowing) + updateDisplay(_.copy(showing = newShowing)).copy(text = newText) case Some((_, selected)) => val newShowing = newFiltered.headOption.map: (_, id) => @@ -218,21 +214,10 @@ private[cue4s] object InteractiveMultipleChoice: newFiltered.find(_._2 == selected).map(_._2).getOrElse(id) newFiltered.map(_._2) -> newSelected - copy(text = newText, showing = newShowing) - else copy(showing = None, text = newText) + updateDisplay(_.copy(showing = newShowing)).copy(text = newText) + else updateDisplay(_.copy(showing = None)).copy(text = newText) end if end changeText - override protected def changeSelection(move: Int) = - showing match - case None => this // do nothing, no alternatives are showing - case a @ Some((filtered, showing)) => - val position = filtered.indexOf(showing) - - val newSelected = - (position + move).max(0).min(filtered.length - 1) - - copy(showing = a.map(_ => (filtered, filtered(newSelected)))) - end State end InteractiveMultipleChoice diff --git a/modules/core/src/main/scala/InteractiveSingleChoice.scala b/modules/core/src/main/scala/InteractiveSingleChoice.scala index 3b277f0..a2c2535 100644 --- a/modules/core/src/main/scala/InteractiveSingleChoice.scala +++ b/modules/core/src/main/scala/InteractiveSingleChoice.scala @@ -35,11 +35,13 @@ private[cue4s] class InteractiveSingleChoice( override def initialState = State( text = "", - showing = Some(altsWithIndex.map(_._2) -> 0), all = altsWithIndex, status = Status.Running, - windowStart = 0, - windowSize = windowSize + display = InfiniscrollableState( + showing = Some(altsWithIndex.map(_._2) -> 0), + windowStart = 0, + windowSize = windowSize + ) ) override def handleEvent(event: Event): Next[String] = @@ -48,11 +50,11 @@ private[cue4s] class InteractiveSingleChoice( printPrompt() Next.Continue case Event.Key(KeyEvent.UP) => - stateTransition(_.up) + stateTransition(_.updateDisplay(_.up)) printPrompt() Next.Continue case Event.Key(KeyEvent.DOWN) => - stateTransition(_.down) + stateTransition(_.updateDisplay(_.down)) printPrompt() Next.Continue @@ -98,20 +100,23 @@ private[cue4s] class InteractiveSingleChoice( // prompt question lines += "· " + (prompt.lab + " > ").cyan + st.text - st.showing match + st.display.showing match case None => lines += "no matches...".bold case Some((filtered, selected)) => // Render only the visible window - st.visibleEntries(filtered) + st.display + .visibleEntries(filtered) .zipWithIndex .foreach: case (id, idx) => val alt = altMapping(id) lines.addOne( if id == selected then s" ‣ $alt".green - else if st.windowStart > 0 && idx == 0 then s" ↑ $alt".bold - else if filtered.size > st.windowSize && idx == st.windowSize - 1 && + else if st.display.windowStart > 0 && idx == 0 then + s" ↑ $alt".bold + else if filtered.size > st.display.windowSize && + idx == st.display.windowSize - 1 && filtered.indexOf(id) != filtered.size - 1 then s" ↓ $alt".bold else s" $alt".bold @@ -141,21 +146,15 @@ private[cue4s] object InteractiveSingleChoice: case class State( text: String, - showing: Option[(List[Int], Int)], all: List[(String, Int)], status: Status, - windowStart: Int, - windowSize: Int - ) extends InfiniscrollableState[State]: - - override protected def scrollUp = - copy(windowStart = scrolledUpWindowStart) - - override protected def scrollDown = - copy(windowStart = scrolledDownWindowStart) + display: InfiniscrollableState + ): + def updateDisplay(f: InfiniscrollableState => InfiniscrollableState) = + copy(display = f(display)) def finish = - showing match + display.showing match case None => this case Some((_, selected)) => copy(status = Status.Finished(selected)) @@ -164,24 +163,10 @@ private[cue4s] object InteractiveSingleChoice: def addText(t: Char) = val newText = text + t - changeText(newText).resetWindow() + changeText(newText).updateDisplay(_.resetWindow()) def trimText = - changeText(text.dropRight(1)).resetWindow() - - override protected def resetWindow() = - copy(windowStart = computeWindowStartAfterSearch) - - override protected def changeSelection(move: Int) = - showing match - case None => this // do nothing, no alternatives are showing - case a @ Some((filtered, showing)) => - val position = filtered.indexOf(showing) - - val newSelected = - (position + move).max(0).min(filtered.length - 1) - - copy(showing = a.map(_ => (filtered, filtered(newSelected)))) + changeText(text.dropRight(1)).updateDisplay(_.resetWindow()) private def changeText(newText: String) = val newFiltered = all.filter((alt, _) => @@ -190,12 +175,12 @@ private[cue4s] object InteractiveSingleChoice: .contains(newText.toLowerCase().trim()) ) if newFiltered.nonEmpty then - showing match + display.showing match case None => val newShowing = newFiltered.headOption.map: (_, id) => newFiltered.map(_._2) -> id - copy(text = newText, showing = newShowing) + updateDisplay(_.copy(showing = newShowing)).copy(text = newText) case Some((_, selected)) => val newShowing = newFiltered.headOption.map: (_, id) => @@ -203,8 +188,8 @@ private[cue4s] object InteractiveSingleChoice: newFiltered.find(_._2 == selected).map(_._2).getOrElse(id) newFiltered.map(_._2) -> newSelected - copy(text = newText, showing = newShowing) - else copy(showing = None, text = newText) + updateDisplay(_.copy(showing = newShowing)).copy(text = newText) + else updateDisplay(_.copy(showing = None)).copy(text = newText) end if end changeText end State