2025-09-03 11:06:39 +02:00

234 lines
6.9 KiB
Scala

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, IconSource}
import ocelot.desktop.ui.UiHandler
import ocelot.desktop.ui.event.{ClickEvent, MouseEvent}
import ocelot.desktop.ui.event.handlers.MouseHandler
import ocelot.desktop.ui.layout.{Layout, LinearLayout}
import ocelot.desktop.ui.widget.LogWidget.{BorderThickness, EntryMargin, EntryPadding, LogEntry}
import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry}
import ocelot.desktop.util.{DrawUtils, Orientation}
import scala.annotation.tailrec
import scala.collection.mutable
abstract class LogWidget extends Widget {
override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical)
private object MessageListWidget extends Widget with MouseHandler { messageList =>
override protected val layout: Layout = new Layout(this)
private val entries = mutable.ArrayDeque.empty[Entry]
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)
}
}
private def removedOffset: Float = entries.headOption.map(_.y - EntryMargin).getOrElse(0)
private def applyRemovedOffset(): Unit = {
val offset = removedOffset
for (entry <- entries) {
entry.y -= offset
}
}
override def minimumSize: Size2D = Size2D(
dummyEntry.minimumSize.width + 2 * EntryMargin,
nextMessageY - removedOffset,
)
private def nextMessageY: Float = {
entries.lastOption.map(_.maxY).getOrElse(0f) + EntryMargin
}
def addEntry(entry: LogEntry): Unit = {
entries += (entry match {
case LogEntry.Rx(message) => new RxEntry(message, nextMessageY)
case LogEntry.Tx(message) => new TxEntry(message, nextMessageY)
})
parent.get.recalculateBoundsAndRelayout()
}
def removeFirst(count: Int): Unit = {
if (count >= entries.length) {
entries.clear()
} else {
entries.dropInPlace(count)
}
parent.get.recalculateBoundsAndRelayout()
}
@tailrec
private def firstVisibleIdxSearch(y: Float, from: Int = 0, to: Int = entries.length): Int = {
// search the range [from, to)
if (to <= from) to
else {
val idx = from + (to - from - 1) / 2
val entryY = entries(idx).maxY
if (entryY <= y) firstVisibleIdxSearch(y, idx + 1, to)
else firstVisibleIdxSearch(y, from, idx)
}
}
private def visibleEntries: Iterator[Entry] = {
val firstVisibleIdx = firstVisibleIdxSearch(parent.get.asInstanceOf[ScrollView].offset.y)
entries.iterator.drop(firstVisibleIdx).takeWhile(_.absoluteBounds.y <= clippedBounds.max.y)
}
override def draw(g: Graphics): Unit = {
applyRemovedOffset()
for (entry <- visibleEntries) {
entry.draw(g)
}
super.draw(g)
}
trait Entry {
var y: Float
def maxY: Float
def draw(g: Graphics): Unit
def minimumSize: Size2D
def size: Size2D
private def position: Vector2D = Vector2D(EntryMargin, y)
final def absoluteBounds: Rect2D = Rect2D(position + messageList.position, size)
}
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(
textWidth * 8 +
EntryPadding * 2 +
BorderThickness * 2,
Text.CharHeight * (lines.length max 1) +
EntryPadding * 2 +
BorderThickness * 2,
)
def size: Size2D = Size2D(
minimumSize.width max (messageList.size.width - 2 * EntryMargin),
minimumSize.height,
)
override def maxY: Float = y + minimumSize.height
def background: RGBAColorNorm
def foreground: RGBAColorNorm
def border: RGBAColorNorm
override def draw(g: Graphics): Unit = {
val innerBounds = absoluteBounds.inflated(-BorderThickness)
g.rect(innerBounds, background)
DrawUtils.ring(
g,
absoluteBounds.x,
absoluteBounds.y,
absoluteBounds.w,
absoluteBounds.h,
BorderThickness,
border,
)
g.foreground = foreground
DrawUtils.drawTextLines(
g,
absoluteBounds.x + BorderThickness + EntryPadding,
absoluteBounds.y + BorderThickness + EntryPadding,
lines,
)
}
}
private class RxEntry(message: String, _y: Float) extends MessageEntry(message, _y) {
override def background: RGBAColorNorm = ColorScheme("LogEntryBackground")
override def foreground: RGBAColorNorm = ColorScheme("Label")
override def border: RGBAColorNorm = ColorScheme("LogEntryBorder")
}
private class TxEntry(message: String, _y: Float) extends MessageEntry(message, _y) {
override def background: RGBAColorNorm = ColorScheme("LogEntryTxBackground")
override def foreground: RGBAColorNorm = ColorScheme("LogEntryTxForeground")
override def border: RGBAColorNorm = ColorScheme("LogEntryTxBorder")
}
}
private val scrollView = new ScrollView(MessageListWidget)
children :+= new PaddingBox(scrollView, Padding2D.equal(BorderThickness))
def scrollToEnd: Boolean = scrollView.scrollToEnd
def scrollToEnd_=(value: Boolean): Unit = {
scrollView.scrollToEnd = value
}
protected def textWidth: Int
override def minimumSize: Size2D = Size2D(
MessageListWidget.minimumSize.width + 2 * BorderThickness,
MessageListWidget.dummyEntry.maxY +
EntryMargin +
BorderThickness * 2, // outer border (top + bottom)
)
def addEntries(entries: Iterable[LogEntry]): Unit = {
for (entry <- entries) {
MessageListWidget.addEntry(entry)
}
}
def removeFirstEntries(count: Int): Unit = {
MessageListWidget.removeFirst(count)
}
override def draw(g: Graphics): Unit = {
g.rect(bounds, ColorScheme("LogBackground"))
DrawUtils.ring(g, bounds.x, bounds.y, bounds.w, bounds.h, BorderThickness, ColorScheme("LogBorder"))
super.draw(g)
}
}
object LogWidget {
val BorderThickness = 2
val EntryPadding = 2
val EntryMargin = 2
sealed trait LogEntry
object LogEntry {
/** A message received from a log source. */
case class Rx(message: String) extends LogEntry
/** A message sent to a log source. */
case class Tx(message: String) extends LogEntry
}
}