diff --git a/src/main/scala/ocelot/desktop/ui/event/MouseEvent.scala b/src/main/scala/ocelot/desktop/ui/event/MouseEvent.scala index 2ac0bda..5f27c6b 100644 --- a/src/main/scala/ocelot/desktop/ui/event/MouseEvent.scala +++ b/src/main/scala/ocelot/desktop/ui/event/MouseEvent.scala @@ -1,5 +1,8 @@ package ocelot.desktop.ui.event +case class MouseEvent(state: MouseEvent.State.Value, button: MouseEvent.Button.Value)(val stateChanged: Boolean) + extends CapturingEvent + object MouseEvent { object State extends Enumeration { val Pressed, Released = Value @@ -10,6 +13,14 @@ object MouseEvent { val Right: Button.Value = Value(1) val Middle: Button.Value = Value(2) } -} -case class MouseEvent(state: MouseEvent.State.Value, button: MouseEvent.Button.Value) extends CapturingEvent + object StateChanged { + def unapply(event: MouseEvent): Option[(State.Value, Button.Value)] = { + if (event.stateChanged) { + MouseEvent.unapply(event) + } else { + None + } + } + } +} diff --git a/src/main/scala/ocelot/desktop/ui/event/handlers/MouseHandler.scala b/src/main/scala/ocelot/desktop/ui/event/handlers/MouseHandler.scala index ba05484..b4ee378 100644 --- a/src/main/scala/ocelot/desktop/ui/event/handlers/MouseHandler.scala +++ b/src/main/scala/ocelot/desktop/ui/event/handlers/MouseHandler.scala @@ -24,7 +24,7 @@ trait MouseHandler extends Widget { protected def receiveDragEvents: Boolean = false /** - * If `true`, drag events, once they start, will be sent on every update cycle when if the mouse does not move. + * If `true`, drag events, once they start, will be sent on every update cycle even if the mouse does not move. */ protected def spamDragEvents: Boolean = true diff --git a/src/main/scala/ocelot/desktop/ui/event/sources/MouseEvents.scala b/src/main/scala/ocelot/desktop/ui/event/sources/MouseEvents.scala index 4f299aa..69f78b4 100644 --- a/src/main/scala/ocelot/desktop/ui/event/sources/MouseEvents.scala +++ b/src/main/scala/ocelot/desktop/ui/event/sources/MouseEvents.scala @@ -26,13 +26,13 @@ object MouseEvents { if (MouseEvent.Button.values.map(_.id).contains(buttonIdx)) { val button = MouseEvent.Button(buttonIdx) val state = if (Mouse.getEventButtonState) MouseEvent.State.Pressed else MouseEvent.State.Released - _events += MouseEvent(state, button) - state match { + val changed = state match { case MouseEvent.State.Pressed => - _pressedButtons += button + _pressedButtons.add(button) case MouseEvent.State.Released => - _pressedButtons -= button + _pressedButtons.remove(button) } + _events += MouseEvent(state, button)(changed) } val delta = Mouse.getEventDWheel @@ -49,7 +49,7 @@ object MouseEvents { def releaseButtons(): Unit = { for (button <- pressedButtons) { - _events += MouseEvent(MouseEvent.State.Released, button) + _events += MouseEvent(MouseEvent.State.Released, button)(stateChanged = true) } _pressedButtons.clear() diff --git a/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala b/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala index 59e9c4c..5e9f486 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala @@ -17,7 +17,6 @@ import org.lwjgl.input.Keyboard import java.lang.Character.isWhitespace - class TextInput(val initialText: String = "") extends Widget with MouseHandler with HoverAnimation { private val CursorBlinkTime = 2f private val PlaceholderForegroundColor: Color = ColorScheme("TextInputForegroundDisabled") @@ -29,33 +28,44 @@ 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 selection: Selection = new Selection() + private val selectionWatcher = new 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 private var scroll = 0f private var blinkTimer = 0f private var cursorOffset = 0f - private var selectionStartOffset = 0f - private var selectionEndOffset = 0f + private var selectionOffsets: Option[(Int, Int)] = None private val enabledRegister = Register.sampling(enabled) + private def selection: Option[Selection] = selectionWatcher.value + private def selection_=(newValue: Option[Selection]): Unit = { + selectionWatcher.value = newValue + } + cursor.onChange(position => { cursorOffset = charsWidth(_text.chars, 0, position) blinkTimer = 0 adjustScroll() }) - selection.onChange((from, to) => { - selectionStartOffset = charsWidth(_text.chars, 0, from min to) - selectionEndOffset = charsWidth(_text.chars, 0, from max to) + selectionWatcher.onChange(newValue => { + selectionOffsets = newValue.map { + case Selection.Ordered(start, end) => + ( + charsWidth(_text.chars, 0, start), + charsWidth(_text.chars, 0, end), + ) + } }) private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f) private val borderAnimation = new ColorAnimation(targetBorderColor, 7f) - // public API // ------------------------------------------------------------------------------------------------------------------- def onInput(text: String): Unit = {} @@ -70,11 +80,14 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w def text: String = new String(_text.chars, 0, _text.chars.length) def text_=(value: String): Unit = { _text.chars = value.codePoints().toArray - selection.active = false + selection = None cursor.position = cursor.position max 0 min _text.chars.length } - private def selectedText: String = new String(_text.chars, selection.start, selection.length) + private def selectedText: String = selection match { + case Some(Selection.Ordered(start, end)) => new String(_text.chars, start, end) + case None => "" + } protected var placeholder: Array[Int] = Array.empty def placeholder_=(value: String): Unit = placeholder = value.codePoints().toArray @@ -96,7 +109,6 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w } } - // widget management // ------------------------------------------------------------------------------------------------------------------- override def minimumSize: Size2D = Size2D(200, 24) @@ -118,7 +130,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w * @param from inclusive * @param to exclusive */ - //noinspection SameParameterValue + // noinspection SameParameterValue private def charsWidth(chars: Array[Int], from: Int, to: Int): Int = { var width = 0 for (index <- (from max 0) until (to min chars.length)) { @@ -128,79 +140,77 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w } eventHandlers += { - case MouseEvent(MouseEvent.State.Pressed, button) if enabled => + case MouseEvent.StateChanged(MouseEvent.State.Pressed, _) if enabled => val inBounds = mouseInBounds if (isFocused && !inBounds) unfocus() if (!isFocused && inBounds) focus() + if (isFocused) { val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x) - val clamped = _text.chars.length.min(pos).max(0) - // a bit of special logic: right click moves cursor position, but only if we clicked outside the current selection - // otherwise it would be impossible to do context operations on the selected text - if (button != MouseEvent.Button.Right || !selection.active || clamped < selection.from || clamped > selection.to) { - setCursorAndSelectionPosition(clamped) - } + cursor.position = pos } } eventHandlers += { - case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Right) if isFocused && mouseInBounds => + case MouseEvent.StateChanged(MouseEvent.State.Pressed, MouseEvent.Button.Right) if isFocused && mouseInBounds => val menu = new ContextMenu - if (selection.active) { + + if (selection.nonEmpty) { menu.addEntry(ContextMenuEntry("Cut", IconSource.Icons.Cut) { cutSelection() }) menu.addEntry(ContextMenuEntry("Copy", IconSource.Icons.Copy) { copySelection() }) } + if (UiHandler.clipboard.nonEmpty) { menu.addEntry(ContextMenuEntry("Paste", IconSource.Icons.Paste) { pasteSelection() }) } - if (menu.children.nonEmpty) { - menu.addSeparator() + + if (_text.chars.nonEmpty) { + if (menu.children.nonEmpty) { + menu.addSeparator() + } + + menu.addEntry(ContextMenuEntry("Select all") { selectAll() }) } - menu.addEntry(ContextMenuEntry("Select all") { selectAll() }) + root.get.contextMenus.open(menu) + case MouseEvent.StateChanged(MouseEvent.State.Pressed, _) if isFocused && mouseInBounds => + selection = None + case DoubleClickEvent(MouseEvent.Button.Left, _) if isFocused && mouseInBounds => selectWord() - case event @ DragEvent(DragEvent.State.Start, MouseEvent.Button.Left, mouse) if isFocused => + case DragEvent(DragEvent.State.Start | DragEvent.State.Drag, MouseEvent.Button.Left, mouse) if isFocused => val pos = pixelToCursorPosition(mouse.x - bounds.x) - selection.active = true - selection.to = pos + selection = Selection(selection.fold(prevCursorPosition.value)(_.start), pos) cursor.position = pos - event.consume() - - case event @ DragEvent(DragEvent.State.Drag, MouseEvent.Button.Left, mouse) if isFocused => - val pos = pixelToCursorPosition(mouse.x - bounds.x) - selection.to = pos - cursor.position = pos - event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused => - if (selection.active && !KeyEvents.isShiftDown) { - setCursorAndSelectionPosition(selection.start) - } else if (cursor.position > 0) { - setCursorAndSelectionPosition(cursor.position - 1) - } + handleKeyMovement(selection match { + case Some(Selection.Ordered(start, _)) if !KeyEvents.isShiftDown => start + case _ => cursor.position - 1 + }) + event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused => - if (selection.active && !KeyEvents.isShiftDown) { - setCursorAndSelectionPosition(selection.end) - } else if (cursor.position < _text.chars.length) { - setCursorAndSelectionPosition(cursor.position + 1) - } + handleKeyMovement(selection match { + case Some(Selection.Ordered(_, end)) if !KeyEvents.isShiftDown => end + case _ => cursor.position + 1 + }) + event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused => - setCursorAndSelectionPosition(0) + handleKeyMovement(0) event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused => - setCursorAndSelectionPosition(_text.chars.length) + handleKeyMovement(_text.chars.length) event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused => - if (selection.active) { + if (selection.nonEmpty) { deleteSelection() } else { val (lhs, rhs) = _text.chars.splitAt(cursor.position) @@ -209,10 +219,11 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w cursor.position -= 1 } } + event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_DELETE, _) if isFocused => - if (selection.active) { + if (selection.nonEmpty) { deleteSelection() } else { val (lhs, rhs) = _text.chars.splitAt(cursor.position) @@ -220,6 +231,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w _text.chars = lhs ++ rhs.drop(1) } } + event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_A, _) @@ -233,12 +245,12 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_C, _) - if isFocused && KeyEvents.isControlDown && selection.active => + if isFocused && KeyEvents.isControlDown && selection.nonEmpty => copySelection() event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_X, _) - if isFocused && KeyEvents.isControlDown && selection.active => + if isFocused && KeyEvents.isControlDown && selection.nonEmpty => cutSelection() event.consume() @@ -256,7 +268,10 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, _, char) if isFocused && !char.isControl => - if (selection.active) deleteSelection() + if (selection.nonEmpty) { + deleteSelection() + } + writeChar(char) event.consume() } @@ -276,36 +291,25 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w pos } - /** - * Generally you can just do `cursor.position = x`. - * But this method will take care of correctly repositioning the selection as well. - */ - private def setCursorAndSelectionPosition(position: Int): Unit = { - if (!selection.active && KeyEvents.isShiftDown && cursor.position != position) { - selection.active = true - selection.from = cursor.position - } else if (selection.active && !KeyEvents.isShiftDown) { - selection.active = false - } - cursor.position = position - if (selection.active) { - selection.to = cursor.position + private def handleKeyMovement(position: Int): Unit = { + selection = if (KeyEvents.isShiftDown) { + Selection(selection.fold(cursor.position)(_.start), position) } else { - selection.from = cursor.position + None } + + cursor.position = position } private def selectAll(): Unit = { - selection.active = true - selection.from = 0 - selection.to = _text.chars.length + selection = Selection(0, _text.chars.length) } private def selectWord(): Unit = { - selection.active = true - selection.from = 0 max (_text.chars.lastIndexWhere(isWhitespace, cursor.position - 1) + 1) + val from = 0 max (_text.chars.lastIndexWhere(isWhitespace, cursor.position - 1) + 1) val to = _text.chars.indexWhere(isWhitespace, cursor.position) - selection.to = if (to >= 0 && to < _text.chars.length) to else _text.chars.length + val clampedTo = if (to >= 0 && to < _text.chars.length) to else _text.chars.length + selection = Selection(from, clampedTo) } private def copySelection(): Unit = { @@ -318,14 +322,15 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w } private def pasteSelection(): Unit = { - if (selection.active) deleteSelection() + if (selection.nonEmpty) deleteSelection() writeString(UiHandler.clipboard) } private def deleteSelection(): Unit = { - selection.active = false - _text.chars = _text.chars.take(selection.start) ++ _text.chars.drop(selection.end) - cursor.position = selection.start + for (Selection.Ordered(start, end) <- selection) { + _text.chars = _text.chars.take(start) ++ _text.chars.drop(end) + cursor.position = start + } } private def writeString(string: String): Unit = { @@ -354,7 +359,6 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w if (fullTextWidth > areaWidth && fullTextWidth - scroll < areaWidth) scroll = fullTextWidth - areaWidth } - override def update(): Unit = { super.update() @@ -373,12 +377,12 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w } // update everything + prevCursorPosition.update() foregroundAnimation.update() borderAnimation.update() blinkTimer = (blinkTimer + UiHandler.dt) % CursorBlinkTime } - private def updateAnimationTargets(): Unit = { foregroundAnimation.goto(targetForegroundColor) borderAnimation.goto(targetBorderColor) @@ -406,9 +410,9 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w g.setScissor(position.x + 4, position.y, size.width - 8f, size.height) - if (selection.active) { - val width = selectionEndOffset - selectionStartOffset - g.rect(position.x + selectionStartOffset + 8 - scroll, position.y + 4, width, size.height - 8, BackgroundSelectedColor) + for ((start, end) <- selectionOffsets) { + val width = end - start + g.rect(position.x + start + 8 - scroll, position.y + 4, width, size.height - 8, BackgroundSelectedColor) } g.background = Color.Transparent @@ -418,9 +422,10 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w var charOffset = 0 val charsToDisplay = if (_text.chars.nonEmpty || isFocused) _text.chars else placeholder for (char <- charsToDisplay) { - if (selection.active) { - g.foreground = if (charOffset >= selectionStartOffset && charOffset < selectionEndOffset) ForegroundSelectedColor else foreground + for ((start, end) <- selectionOffsets) { + g.foreground = if (charOffset >= start && charOffset < end) ForegroundSelectedColor else foreground } + g.char(position.x + 8 + charOffset - scroll, position.y + 4, char) charOffset += g.font.charWidth(char) } @@ -436,29 +441,29 @@ object TextInput { def chars: Array[Int] = value def chars_=(newValue: Array[Int]): Unit = value = newValue } + class Cursor(initialValue: Int = 0) extends Watcher(initialValue) { def position: Int = value def position_=(newValue: Int): Unit = value = newValue } - class Selection { - private val fromWatcher = Watcher(0) - private val toWatcher = Watcher(0) - def from: Int = fromWatcher.value - def from_=(x: Int): Unit = fromWatcher.value = x + case class Selection(start: Int, end: Int) { + require(start != end) + } - def to: Int = toWatcher.value - def to_=(x: Int): Unit = toWatcher.value = x + object Selection { + def apply(start: Int, end: Int): Option[Selection] = { + Option.when(start != end) { + new Selection(start, end) + } + } - def start: Int = from min to - def end: Int = from max to - def length: Int = end - start + object Ordered { + def unapply(selection: Selection): Some[(Int, Int)] = { + val Selection(start, end) = selection - var active: Boolean = false - - def onChange(callback: (Int, Int) => Unit): Unit = { - fromWatcher.onChange(callback(_, to)) - toWatcher.onChange(callback(from, _)) + Some((start min end, start max end)) + } } } }