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 } }