diff --git a/src/main/scala/ocelot/desktop/OcelotDesktop.scala b/src/main/scala/ocelot/desktop/OcelotDesktop.scala index 0844fd6..868e98b 100644 --- a/src/main/scala/ocelot/desktop/OcelotDesktop.scala +++ b/src/main/scala/ocelot/desktop/OcelotDesktop.scala @@ -55,7 +55,7 @@ object OcelotDesktop extends LoggingConfiguration with Logging { def emulationPaused_=(paused: Boolean): Unit = { _emulationPaused = paused // avoid sudden jumps of TPS counter after a pause - if (!paused) tpsCounter.skipFrame() + if (!paused) tpsCounter.skipSecond() } private val TickerIntervalHistorySize = 5 diff --git a/src/main/scala/ocelot/desktop/ui/widget/LogWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/LogWidget.scala index b9953b6..17932c5 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/LogWidget.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/LogWidget.scala @@ -3,28 +3,59 @@ package ocelot.desktop.ui.widget import ocelot.desktop.ColorScheme import ocelot.desktop.color.RGBAColorNorm import ocelot.desktop.geometry.{Padding2D, Rect2D, Size2D, Vector2D} -import ocelot.desktop.graphics.Graphics -import ocelot.desktop.ui.layout.{Layout, LinearLayout} +import ocelot.desktop.graphics.{Graphics, IconSource} +import ocelot.desktop.ui.UiHandler +import ocelot.desktop.ui.event.handlers.MouseHandler +import ocelot.desktop.ui.event.{ClickEvent, MouseEvent} +import ocelot.desktop.ui.layout.{CopyLayout, Layout} import ocelot.desktop.ui.widget.LogWidget.{BorderThickness, EntryMargin, EntryPadding, LogEntry} -import ocelot.desktop.util.{DrawUtils, Orientation} +import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} +import ocelot.desktop.util.DrawUtils import scala.annotation.tailrec import scala.collection.mutable abstract class LogWidget extends Widget { - override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) + override protected val layout: Layout = new CopyLayout(this) - private object MessageListWidget extends Widget { messageList => - override protected val layout: Layout = new Layout(this) + private object MessageListWidget extends Widget with MouseHandler { messageList => + override protected val layout: Layout = new Layout(this) { + override def recalculateBounds(): Unit = { + super.recalculateBounds() + } + } private val entries = mutable.ArrayDeque.empty[Entry] + private var needRelayout = false val dummyEntry: Entry = new RxEntry("", nextMessageY) + override def receiveClickEvents: Boolean = true + + eventHandlers += { + case ClickEvent(MouseEvent.Button.Right, mouse) => + visibleEntries.find(_.absoluteBounds.contains(mouse)).foreach { + case entry: MessageEntry => + val menu = new ContextMenu + menu.addEntry(ContextMenuEntry("Copy", IconSource.Icons.Copy) { + UiHandler.clipboard = entry.message + }) + root.get.contextMenus.open(menu) + } + } + + // when entries are removed from the head, all consecutive entries need to be shifted up. + // doing that for every single removal is **very** inefficient. + // instead, we keep the widgets where they are without shifting, having the offset accumulate, and update their + // positions only once per frame (in `draw`). private def removedOffset: Float = entries.headOption.map(_.y - EntryMargin).getOrElse(0) private def applyRemovedOffset(): Unit = { val offset = removedOffset + + if (offset == 0) { + return + } for (entry <- entries) { entry.y -= offset @@ -36,6 +67,8 @@ abstract class LogWidget extends Widget { nextMessageY - removedOffset, ) + override def maximumSize: Size2D = minimumSize + private def nextMessageY: Float = { entries.lastOption.map(_.maxY).getOrElse(0f) + EntryMargin } @@ -46,7 +79,7 @@ abstract class LogWidget extends Widget { case LogEntry.Tx(message) => new TxEntry(message, nextMessageY) }) - parent.get.recalculateBoundsAndRelayout() + needRelayout = true } def removeFirst(count: Int): Unit = { @@ -56,7 +89,7 @@ abstract class LogWidget extends Widget { entries.dropInPlace(count) } - parent.get.recalculateBoundsAndRelayout() + needRelayout = true } @tailrec @@ -72,15 +105,25 @@ abstract class LogWidget extends Widget { } } - override def draw(g: Graphics): Unit = { - applyRemovedOffset() - + private def visibleEntries: Iterator[Entry] = { val firstVisibleIdx = firstVisibleIdxSearch(parent.get.asInstanceOf[ScrollView].offset.y) + entries.iterator.drop(firstVisibleIdx).takeWhile(_.absoluteBounds.y <= clippedBounds.max.y) + } - for (entry <- entries.iterator.drop(firstVisibleIdx).takeWhile(_.absoluteBounds.y <= clippedBounds.max.y)) { - entry.draw(g) + override def update(): Unit = { + if (needRelayout) { + recalculateBoundsAndRelayout() + needRelayout = false } + super.update() + } + + override def draw(g: Graphics): Unit = { + applyRemovedOffset() + for (entry <- visibleEntries) { + entry.draw(g) + } super.draw(g) } @@ -100,7 +143,7 @@ abstract class LogWidget extends Widget { final def absoluteBounds: Rect2D = Rect2D(position + messageList.position, size) } - private abstract class MessageEntry(message: String, override var y: Float) extends Entry { + private abstract class MessageEntry(val message: String, override var y: Float) extends Entry { private val lines = Text.wrap(message, Some(textWidth)) def minimumSize: Size2D = Size2D( @@ -203,12 +246,16 @@ object LogWidget { sealed trait LogEntry + sealed trait TextLogEntry extends LogEntry { + val message: String + } + object LogEntry { /** A message received from a log source. */ - case class Rx(message: String) extends LogEntry + case class Rx(message: String) extends TextLogEntry /** A message sent to a log source. */ - case class Tx(message: String) extends LogEntry + case class Tx(message: String) extends TextLogEntry } } diff --git a/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala b/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala index 4f35a7b..fc16c2f 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala @@ -11,7 +11,7 @@ import ocelot.desktop.ui.event.{DoubleClickEvent, DragEvent, KeyEvent, MouseEven import ocelot.desktop.ui.widget.TextInput.{Cursor, Selection, Text} import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} import ocelot.desktop.ui.widget.traits.HoverAnimation -import ocelot.desktop.util.{DrawUtils, Register, Watcher} +import ocelot.desktop.util.{BaseWatcher, DrawUtils, Register, Watcher} import ocelot.desktop.util.animation.ColorAnimation import org.lwjgl.input.Keyboard @@ -28,13 +28,13 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w // model private val _text: Text = new Text(initialText.codePoints().toArray) private val cursor: Cursor = new Cursor() - private val selectionWatcher = new Watcher[Option[Selection]](None) + private val selectionWatcher = Watcher[Option[Selection]](None) // updated after all events are processed so that event handlers can refer to the previous position. private val prevCursorPosition = Register.sampling(cursor.position) // view - private var isFocused = false + protected var isFocused = false private var scroll = 0f private var blinkTimer = 0f private var cursorOffset = 0f @@ -47,13 +47,13 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w selectionWatcher.value = newValue } - cursor.onChange(position => { + cursor.onChange = { position => cursorOffset = charsWidth(_text.chars, 0, position) - blinkTimer = 0 - adjustScroll() - }) + blinkTimer = 0 + adjustScroll() + } - selectionWatcher.onChange(newValue => { + selectionWatcher.onChange = { newValue => selectionOffsets = newValue.map { case Selection.Ordered(start, end) => ( @@ -61,7 +61,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w charsWidth(_text.chars, 0, end), ) } - }) + } private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f) private val borderAnimation = new ColorAnimation(targetBorderColor, 7f) @@ -81,7 +81,10 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w def text_=(value: String): Unit = { _text.chars = value.codePoints().toArray selection = None - cursor.position = cursor.position max 0 min _text.chars.length + + val desiredPosition = cursor.desiredPosition + cursor.position = desiredPosition max 0 min _text.chars.length + cursor.desiredPosition = desiredPosition } private def selectedText: String = selection match { @@ -448,14 +451,20 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w } object TextInput { - class Text(initialValue: Array[Int]) extends Watcher(initialValue) { + class Text(initialValue: Array[Int]) extends BaseWatcher(initialValue) { def chars: Array[Int] = value def chars_=(newValue: Array[Int]): Unit = value = newValue } - class Cursor(initialValue: Int = 0) extends Watcher(initialValue) { + class Cursor(initialValue: Int = 0) extends BaseWatcher(initialValue) { + var desiredPosition: Int = initialValue + def position: Int = value - def position_=(newValue: Int): Unit = value = newValue + + def position_=(newValue: Int): Unit = { + value = newValue + desiredPosition = newValue + } } case class Selection(start: Int, end: Int) { diff --git a/src/main/scala/ocelot/desktop/util/FPSCalculator.scala b/src/main/scala/ocelot/desktop/util/FPSCalculator.scala index b051b68..196d8d6 100644 --- a/src/main/scala/ocelot/desktop/util/FPSCalculator.scala +++ b/src/main/scala/ocelot/desktop/util/FPSCalculator.scala @@ -9,29 +9,31 @@ class FPSCalculator { def fps: Float = _fps - private var _skipFrame = false + private var _skipSecond = false /** - * Next tick will not count towards the overall statistics. + * Current second will not count towards the overall statistics. + * (In case the measurements were corrupted or distorted somehow.) */ - def skipFrame(): Unit = _skipFrame = true + def skipSecond(): Unit = _skipSecond = true var dt: Float = 0 def tick(): Unit = { val currentTime = System.currentTimeMillis() - if (!_skipFrame) { - dt = (currentTime - prevFrameTime) / 1000f + dt = (currentTime - prevFrameTime) / 1000f + numFrames += 1 - numFrames += 1 - - if (currentTime - prevTime > 1000) { - val delta = currentTime - prevTime - prevTime = currentTime - _fps = numFrames.asInstanceOf[Float] / delta * 1000f - numFrames = 0 + if (currentTime - prevTime > 1000) { + val delta = currentTime - prevTime + prevTime = currentTime + if (!_skipSecond) { + _fps = numFrames.toFloat / delta * 1000.0f + } else { + _skipSecond = false } + numFrames = 0 } prevFrameTime = currentTime diff --git a/src/main/scala/ocelot/desktop/util/OcelotInterfaceLogStorage.scala b/src/main/scala/ocelot/desktop/util/OcelotInterfaceLogStorage.scala index 820e81b..c498120 100644 --- a/src/main/scala/ocelot/desktop/util/OcelotInterfaceLogStorage.scala +++ b/src/main/scala/ocelot/desktop/util/OcelotInterfaceLogStorage.scala @@ -2,7 +2,7 @@ package ocelot.desktop.util import ocelot.desktop.entity.traits.OcelotInterface import ocelot.desktop.ui.event.{BrainEvent, EventAware} -import ocelot.desktop.ui.widget.LogWidget.LogEntry +import ocelot.desktop.ui.widget.LogWidget.{LogEntry, TextLogEntry} import ocelot.desktop.ui.widget.window.Windowed import ocelot.desktop.util.OcelotInterfaceLogStorage._ import ocelot.desktop.windows.OcelotInterfaceWindow @@ -121,6 +121,16 @@ trait OcelotInterfaceLogStorage extends EventAware with Persistable with Windowe window.onMessagesRemoved(count) } + def getEntry(index: Int): LogEntry = _entries(index) + + def findFirstTextEntry(exclude: String, start: Int): Int = { + _entries.indexWhere(it => it.isInstanceOf[TextLogEntry] && it.asInstanceOf[TextLogEntry].message != exclude, start) + } + + def findLastTextEntry(exclude: String, end: Int): Int = { + _entries.lastIndexWhere(it => it.isInstanceOf[TextLogEntry] && it.asInstanceOf[TextLogEntry].message != exclude, end) + } + private def addEntry(entry: LogEntry): Unit = { ensureFreeSpace(1) _entries += entry diff --git a/src/main/scala/ocelot/desktop/util/Watcher.scala b/src/main/scala/ocelot/desktop/util/Watcher.scala index 0e8831a..959d8b4 100644 --- a/src/main/scala/ocelot/desktop/util/Watcher.scala +++ b/src/main/scala/ocelot/desktop/util/Watcher.scala @@ -1,24 +1,27 @@ package ocelot.desktop.util /** - * Keeps a reference to an object - * and tells whether there were any changes to the value since the last check. + * Keeps a value and tracks its changes. */ -class Watcher[T](initialValue: T) { +abstract class BaseWatcher[T](initialValue: T) { private var dirty = false private var _callback: Option[T => Unit] = None private var _value: T = initialValue - def value: T = _value - def value_=(newValue: T): Unit = { + protected def value: T = _value + protected def value_=(newValue: T): Unit = { dirty = _value != newValue if (dirty) { _callback.foreach(_(newValue)) } _value = newValue } + + def onChange: Option[T => Unit] = _callback - def onChange(callback: T => Unit): Unit = _callback = Some(callback) + def onChange_=(callback: T => Unit): Unit = { + _callback = Some(callback) + } def changed(): Boolean = { if (dirty) { @@ -29,5 +32,10 @@ class Watcher[T](initialValue: T) { } object Watcher { - def apply[T](value: T) = new Watcher(value) + def apply[T](initialValue: T) = new Watcher(initialValue) + + class Watcher[T](initialValue: T) extends BaseWatcher(initialValue) { + override def value: T = super.value + override def value_=(newValue: T): Unit = super.value_=(newValue) + } } diff --git a/src/main/scala/ocelot/desktop/windows/OcelotInterfaceWindow.scala b/src/main/scala/ocelot/desktop/windows/OcelotInterfaceWindow.scala index 985481d..5aa19f1 100644 --- a/src/main/scala/ocelot/desktop/windows/OcelotInterfaceWindow.scala +++ b/src/main/scala/ocelot/desktop/windows/OcelotInterfaceWindow.scala @@ -1,11 +1,15 @@ package ocelot.desktop.windows import ocelot.desktop.geometry.{Padding2D, Size2D} +import ocelot.desktop.ui.event.KeyEvent import ocelot.desktop.ui.layout.{AlignItems, Layout, LinearLayout} -import ocelot.desktop.ui.widget.LogWidget.LogEntry +import ocelot.desktop.ui.widget.LogWidget.{LogEntry, TextLogEntry} import ocelot.desktop.ui.widget.window.PanelWindow import ocelot.desktop.ui.widget.{Button, Checkbox, Filler, Label, LogWidget, PaddingBox, TextInput, Widget} import ocelot.desktop.util.{OcelotInterfaceLogStorage, Orientation} +import ocelot.desktop.windows.OcelotInterfaceWindow.ScrollToEndTag +import org.lwjgl.input.Keyboard +import totoro.ocelot.brain.nbt.NBTTagCompound class OcelotInterfaceWindow(storage: OcelotInterfaceLogStorage) extends PanelWindow { override protected def title: String = s"${storage.name} ${storage.ocelotInterface.node.address}" @@ -21,10 +25,36 @@ class OcelotInterfaceWindow(storage: OcelotInterfaceLogStorage) extends PanelWin children :+= logWidget + var historyPosition = 0 + var unfinishedEntryBackup: Option[String] = None + children :+= new TextInput() { + eventHandlers += { + case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_UP | Keyboard.KEY_DOWN, _) if isFocused => + val currentInput = text + val index = if (event.code == Keyboard.KEY_UP) { + storage.findLastTextEntry(currentInput, storage.entryCount - historyPosition) + } else { + storage.findFirstTextEntry(currentInput, storage.entryCount - historyPosition + 1) + } + if (index != -1) { + if (unfinishedEntryBackup.isEmpty) { + unfinishedEntryBackup = Some(currentInput) + } + historyPosition = storage.entryCount - index + text = storage.getEntry(index).asInstanceOf[TextLogEntry].message + event.consume() + } else if (event.code == Keyboard.KEY_DOWN && unfinishedEntryBackup.nonEmpty) { + text = unfinishedEntryBackup.get + unfinishedEntryBackup = None + } + } + override def onConfirm(): Unit = { pushLine(text) text = "" + unfinishedEntryBackup = None + historyPosition = 0 } } @@ -75,7 +105,11 @@ class OcelotInterfaceWindow(storage: OcelotInterfaceLogStorage) extends PanelWin children :+= new Button { override def text: String = "Clear" - override def onClick(): Unit = clear() + override def onClick(): Unit = { + unfinishedEntryBackup = None + historyPosition = 0 + clear() + } } } }) @@ -95,4 +129,20 @@ class OcelotInterfaceWindow(storage: OcelotInterfaceLogStorage) extends PanelWin def pushLine(line: String): Unit = { storage.ocelotInterface.pushMessage(line) } + + override def save(nbt: NBTTagCompound): Unit = { + super.save(nbt) + nbt.setBoolean(ScrollToEndTag, logWidget.scrollToEnd) + } + + override def load(nbt: NBTTagCompound): Unit = { + super.load(nbt) + if (nbt.hasKey(ScrollToEndTag)) { + logWidget.scrollToEnd = nbt.getBoolean(ScrollToEndTag) + } + } +} + +object OcelotInterfaceWindow { + private val ScrollToEndTag = "scrollToEnd" }