Merge branch 'feature/qol-ocelot-interface' into develop

This commit is contained in:
Fingercomp 2025-09-03 19:33:38 +03:00
commit 5e172ee804
No known key found for this signature in database
GPG Key ID: BBC71CEE45D86E37
7 changed files with 178 additions and 52 deletions

View File

@ -55,7 +55,7 @@ object OcelotDesktop extends LoggingConfiguration with Logging {
def emulationPaused_=(paused: Boolean): Unit = { def emulationPaused_=(paused: Boolean): Unit = {
_emulationPaused = paused _emulationPaused = paused
// avoid sudden jumps of TPS counter after a pause // avoid sudden jumps of TPS counter after a pause
if (!paused) tpsCounter.skipFrame() if (!paused) tpsCounter.skipSecond()
} }
private val TickerIntervalHistorySize = 5 private val TickerIntervalHistorySize = 5

View File

@ -3,29 +3,60 @@ package ocelot.desktop.ui.widget
import ocelot.desktop.ColorScheme import ocelot.desktop.ColorScheme
import ocelot.desktop.color.RGBAColorNorm import ocelot.desktop.color.RGBAColorNorm
import ocelot.desktop.geometry.{Padding2D, Rect2D, Size2D, Vector2D} import ocelot.desktop.geometry.{Padding2D, Rect2D, Size2D, Vector2D}
import ocelot.desktop.graphics.Graphics import ocelot.desktop.graphics.{Graphics, IconSource}
import ocelot.desktop.ui.layout.{Layout, LinearLayout} 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.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.annotation.tailrec
import scala.collection.mutable import scala.collection.mutable
abstract class LogWidget extends Widget { 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 => private object MessageListWidget extends Widget with MouseHandler { messageList =>
override protected val layout: Layout = new Layout(this) override protected val layout: Layout = new Layout(this) {
override def recalculateBounds(): Unit = {
super.recalculateBounds()
}
}
private val entries = mutable.ArrayDeque.empty[Entry] private val entries = mutable.ArrayDeque.empty[Entry]
private var needRelayout = false
val dummyEntry: Entry = new RxEntry("", nextMessageY) 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 removedOffset: Float = entries.headOption.map(_.y - EntryMargin).getOrElse(0)
private def applyRemovedOffset(): Unit = { private def applyRemovedOffset(): Unit = {
val offset = removedOffset val offset = removedOffset
if (offset == 0) {
return
}
for (entry <- entries) { for (entry <- entries) {
entry.y -= offset entry.y -= offset
} }
@ -36,6 +67,8 @@ abstract class LogWidget extends Widget {
nextMessageY - removedOffset, nextMessageY - removedOffset,
) )
override def maximumSize: Size2D = minimumSize
private def nextMessageY: Float = { private def nextMessageY: Float = {
entries.lastOption.map(_.maxY).getOrElse(0f) + EntryMargin entries.lastOption.map(_.maxY).getOrElse(0f) + EntryMargin
} }
@ -46,7 +79,7 @@ abstract class LogWidget extends Widget {
case LogEntry.Tx(message) => new TxEntry(message, nextMessageY) case LogEntry.Tx(message) => new TxEntry(message, nextMessageY)
}) })
parent.get.recalculateBoundsAndRelayout() needRelayout = true
} }
def removeFirst(count: Int): Unit = { def removeFirst(count: Int): Unit = {
@ -56,7 +89,7 @@ abstract class LogWidget extends Widget {
entries.dropInPlace(count) entries.dropInPlace(count)
} }
parent.get.recalculateBoundsAndRelayout() needRelayout = true
} }
@tailrec @tailrec
@ -72,15 +105,25 @@ abstract class LogWidget extends Widget {
} }
} }
override def draw(g: Graphics): Unit = { private def visibleEntries: Iterator[Entry] = {
applyRemovedOffset()
val firstVisibleIdx = firstVisibleIdxSearch(parent.get.asInstanceOf[ScrollView].offset.y) 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) super.draw(g)
} }
@ -100,7 +143,7 @@ abstract class LogWidget extends Widget {
final def absoluteBounds: Rect2D = Rect2D(position + messageList.position, size) 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)) private val lines = Text.wrap(message, Some(textWidth))
def minimumSize: Size2D = Size2D( def minimumSize: Size2D = Size2D(
@ -203,12 +246,16 @@ object LogWidget {
sealed trait LogEntry sealed trait LogEntry
sealed trait TextLogEntry extends LogEntry {
val message: String
}
object LogEntry { object LogEntry {
/** A message received from a log source. */ /** 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. */ /** A message sent to a log source. */
case class Tx(message: String) extends LogEntry case class Tx(message: String) extends TextLogEntry
} }
} }

View File

@ -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.TextInput.{Cursor, Selection, Text}
import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry}
import ocelot.desktop.ui.widget.traits.HoverAnimation 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 ocelot.desktop.util.animation.ColorAnimation
import org.lwjgl.input.Keyboard import org.lwjgl.input.Keyboard
@ -28,13 +28,13 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
// model // model
private val _text: Text = new Text(initialText.codePoints().toArray) private val _text: Text = new Text(initialText.codePoints().toArray)
private val cursor: Cursor = new Cursor() 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. // updated after all events are processed so that event handlers can refer to the previous position.
private val prevCursorPosition = Register.sampling(cursor.position) private val prevCursorPosition = Register.sampling(cursor.position)
// view // view
private var isFocused = false protected var isFocused = false
private var scroll = 0f private var scroll = 0f
private var blinkTimer = 0f private var blinkTimer = 0f
private var cursorOffset = 0f private var cursorOffset = 0f
@ -47,13 +47,13 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
selectionWatcher.value = newValue selectionWatcher.value = newValue
} }
cursor.onChange(position => { cursor.onChange = { position =>
cursorOffset = charsWidth(_text.chars, 0, position) cursorOffset = charsWidth(_text.chars, 0, position)
blinkTimer = 0 blinkTimer = 0
adjustScroll() adjustScroll()
}) }
selectionWatcher.onChange(newValue => { selectionWatcher.onChange = { newValue =>
selectionOffsets = newValue.map { selectionOffsets = newValue.map {
case Selection.Ordered(start, end) => case Selection.Ordered(start, end) =>
( (
@ -61,7 +61,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
charsWidth(_text.chars, 0, end), charsWidth(_text.chars, 0, end),
) )
} }
}) }
private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f) private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f)
private val borderAnimation = new ColorAnimation(targetBorderColor, 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 = { def text_=(value: String): Unit = {
_text.chars = value.codePoints().toArray _text.chars = value.codePoints().toArray
selection = None 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 { private def selectedText: String = selection match {
@ -448,14 +451,20 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
} }
object TextInput { 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: Array[Int] = value
def chars_=(newValue: Array[Int]): Unit = value = newValue 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: 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) { case class Selection(start: Int, end: Int) {

View File

@ -9,29 +9,31 @@ class FPSCalculator {
def fps: Float = _fps 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 var dt: Float = 0
def tick(): Unit = { def tick(): Unit = {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
if (!_skipFrame) {
dt = (currentTime - prevFrameTime) / 1000f dt = (currentTime - prevFrameTime) / 1000f
numFrames += 1 numFrames += 1
if (currentTime - prevTime > 1000) { if (currentTime - prevTime > 1000) {
val delta = currentTime - prevTime val delta = currentTime - prevTime
prevTime = currentTime prevTime = currentTime
_fps = numFrames.asInstanceOf[Float] / delta * 1000f if (!_skipSecond) {
numFrames = 0 _fps = numFrames.toFloat / delta * 1000.0f
} else {
_skipSecond = false
} }
numFrames = 0
} }
prevFrameTime = currentTime prevFrameTime = currentTime

View File

@ -2,7 +2,7 @@ package ocelot.desktop.util
import ocelot.desktop.entity.traits.OcelotInterface import ocelot.desktop.entity.traits.OcelotInterface
import ocelot.desktop.ui.event.{BrainEvent, EventAware} 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.ui.widget.window.Windowed
import ocelot.desktop.util.OcelotInterfaceLogStorage._ import ocelot.desktop.util.OcelotInterfaceLogStorage._
import ocelot.desktop.windows.OcelotInterfaceWindow import ocelot.desktop.windows.OcelotInterfaceWindow
@ -121,6 +121,16 @@ trait OcelotInterfaceLogStorage extends EventAware with Persistable with Windowe
window.onMessagesRemoved(count) 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 = { private def addEntry(entry: LogEntry): Unit = {
ensureFreeSpace(1) ensureFreeSpace(1)
_entries += entry _entries += entry

View File

@ -1,16 +1,15 @@
package ocelot.desktop.util package ocelot.desktop.util
/** /**
* Keeps a reference to an object * Keeps a value and tracks its changes.
* and tells whether there were any changes to the value since the last check.
*/ */
class Watcher[T](initialValue: T) { abstract class BaseWatcher[T](initialValue: T) {
private var dirty = false private var dirty = false
private var _callback: Option[T => Unit] = None private var _callback: Option[T => Unit] = None
private var _value: T = initialValue private var _value: T = initialValue
def value: T = _value protected def value: T = _value
def value_=(newValue: T): Unit = { protected def value_=(newValue: T): Unit = {
dirty = _value != newValue dirty = _value != newValue
if (dirty) { if (dirty) {
_callback.foreach(_(newValue)) _callback.foreach(_(newValue))
@ -18,7 +17,11 @@ class Watcher[T](initialValue: T) {
_value = newValue _value = newValue
} }
def onChange(callback: T => Unit): Unit = _callback = Some(callback) def onChange: Option[T => Unit] = _callback
def onChange_=(callback: T => Unit): Unit = {
_callback = Some(callback)
}
def changed(): Boolean = { def changed(): Boolean = {
if (dirty) { if (dirty) {
@ -29,5 +32,10 @@ class Watcher[T](initialValue: T) {
} }
object Watcher { 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)
}
} }

View File

@ -1,11 +1,15 @@
package ocelot.desktop.windows package ocelot.desktop.windows
import ocelot.desktop.geometry.{Padding2D, Size2D} import ocelot.desktop.geometry.{Padding2D, Size2D}
import ocelot.desktop.ui.event.KeyEvent
import ocelot.desktop.ui.layout.{AlignItems, Layout, LinearLayout} 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.window.PanelWindow
import ocelot.desktop.ui.widget.{Button, Checkbox, Filler, Label, LogWidget, PaddingBox, TextInput, Widget} import ocelot.desktop.ui.widget.{Button, Checkbox, Filler, Label, LogWidget, PaddingBox, TextInput, Widget}
import ocelot.desktop.util.{OcelotInterfaceLogStorage, Orientation} 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 { class OcelotInterfaceWindow(storage: OcelotInterfaceLogStorage) extends PanelWindow {
override protected def title: String = s"${storage.name} ${storage.ocelotInterface.node.address}" override protected def title: String = s"${storage.name} ${storage.ocelotInterface.node.address}"
@ -21,10 +25,36 @@ class OcelotInterfaceWindow(storage: OcelotInterfaceLogStorage) extends PanelWin
children :+= logWidget children :+= logWidget
var historyPosition = 0
var unfinishedEntryBackup: Option[String] = None
children :+= new TextInput() { 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 = { override def onConfirm(): Unit = {
pushLine(text) pushLine(text)
text = "" text = ""
unfinishedEntryBackup = None
historyPosition = 0
} }
} }
@ -75,7 +105,11 @@ class OcelotInterfaceWindow(storage: OcelotInterfaceLogStorage) extends PanelWin
children :+= new Button { children :+= new Button {
override def text: String = "Clear" 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 = { def pushLine(line: String): Unit = {
storage.ocelotInterface.pushMessage(line) 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"
} }