diff --git a/OpenToolkit.GraphicsLibraryFramework/OpenToolkit.GraphicsLibraryFramework.csproj b/OpenToolkit.GraphicsLibraryFramework/OpenToolkit.GraphicsLibraryFramework.csproj
index 0d1cf3a58..b2c9a1fec 100644
--- a/OpenToolkit.GraphicsLibraryFramework/OpenToolkit.GraphicsLibraryFramework.csproj
+++ b/OpenToolkit.GraphicsLibraryFramework/OpenToolkit.GraphicsLibraryFramework.csproj
@@ -5,7 +5,7 @@
$(TargetFramework)
true
- 7.3
+ 9.0
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 14b7e099b..cbf0b7f3c 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -31,11 +31,21 @@ Template for new versions:
### Breaking changes
-*None yet*
+* Thanks to new IME support with SDL2, `IClyde.TextInputStart()` and `IClyde.TextInputStop()` must now be appropriately called to start/stop receiving text input when focusing/unfocusing a UI control. This restriction is applied even on the (default) GLFW backend, to enforce consistent usage of these APIs.
+* `[GUI]TextEventArgs` have been renamed to `[GUI]TextEnteredEventArgs`, turned into records, and made to carry a `string` rather than a single text `Rune`.
### New features
* Fixes for compiling & running on .NET 7. You'll still have to edit a bunch of project files to enable this though.
+* `FormattedMessage.EnumerateRunes()`
+* `OSWindow.Shown()` virtual function for child classes to hook into.
+* `IUserInterfaceManager.DeferAction(...)` for running UI logic "not right now because that would cause an enumeration exception".
+* New `TextEdit` control for multi-line editable text, complete with word-wrapping!
+* `Rope` data structure for representing large editable text, used by the new `TextEdit`.
+* Robust now has IME support matching SDL2's API. This only works on the SDL2 backend (which is not currently enabled by default) but the API is there:
+ * `IClyde.TextInputStart()`, `IClyde.TextInputStop()`, `IClyde.TextInputSetRect()` APIs to control text input behavior.
+ * `TextEditing` events for reporting in-progress IME compositions.
+ * `LineEdit` and `TextEdit` have functional IME support when the game is running on SDL2. If you provide a font file with the relevant glyphs, CJK text input should now be usable.
### Bugfixes
@@ -47,10 +57,19 @@ Template for new versions:
* Properly re-use `HttpClient` in `NetManager` meaning we properly pool connections to the auth server, improving performance.
* Hub advertisements have extended keep-alive pool timeout, so the connection can be kept active between advertisements.
* All HTTP requests from the engine now have appropriate `User-Agent` header.
+* `bind` command has been made somewhat more clear thanks to a bit of help text and some basic completions.
+* `BoundKeyEventArgs` and derivatives now have a `[DebuggerDisplay]`.
+* Text cursors now have a fancy blinking animation.
+* `SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH` is set on the SDL2 windowing backend, so clicking on the game window to focus it will pass clicks through into the game itself, matching GLFW's behavior.
+* Windows clipboard history paste now works.
+* Improved multi-window UI keyboard focusing system: a single focused control is now tracked per UI root (OS window), and is saved/restored when switching between focused window. This means that you (ideally) only ever have a UI control focused on the current OS window.
### Internal
-*None yet*
+* `uitest2` is a new command that's like `uitest` but opens an OS window instead. It can also be passed an argument to open a specific tab immediately.
+* Word-wrapping logic has been split off from `RichTextEntry`, into a new helper struct `WordWrap`.
+* Some internal logic in `LineEdit` has been shared with `TextEdit` by moving it to a new `TextEditShared` file.
+* SDL2 backend now uses `[UnmanagedCallersOnly]` instead of `GetFunctionPointerForDelegate`-style P/Invoke marshalling.
## 0.62.1.0
diff --git a/Resources/Locale/en-US/commands.ftl b/Resources/Locale/en-US/commands.ftl
index 3fd074c03..701f68719 100644
--- a/Resources/Locale/en-US/commands.ftl
+++ b/Resources/Locale/en-US/commands.ftl
@@ -183,6 +183,15 @@ cmd-guidump-help = Usage: guidump
cmd-uitest-desc = Open a dummy UI testing window
cmd-uitest-help = Usage: uitest
+## 'uitest2' command
+cmd-uitest2-desc = Opens a UI control testing OS window
+cmd-uitest2-help = Usage: uitest2
+cmd-uitest2-arg-tab =
+cmd-uitest2-error-args = Expected at most one argument
+cmd-uitest2-error-tab = Invalid tab: '{$value}'
+cmd-uitest2-title = UITest2
+
+
cmd-setclipboard-desc = Sets the system clipboard
cmd-setclipboard-help = Usage: setclipboard
@@ -217,8 +226,15 @@ cmd-cldbglyr-help= Usage: cldbglyr : Toggle
cmd-key-info-desc = Keys key info for a key.
cmd-key-info-help = Usage: keyinfo
-cmd-bind-desc = Binds an input key to an input command.
-cmd-bind-help = bind
+## 'bind' command
+cmd-bind-desc = Binds an input key combination to an input command.
+cmd-bind-help = Usage: bind { cmd-bind-arg-key } { cmd-bind-arg-mode } { cmd-bind-arg-command }
+ Note that this DOES NOT automatically save bindings.
+ Use the 'svbind' command to save binding configuration.
+
+cmd-bind-arg-key =
+cmd-bind-arg-mode =
+cmd-bind-arg-command =
cmd-net-draw-interp-desc = Toggles the debug drawing of the network interpolation.
cmd-net-draw-interp-help = Usage: net_draw_interp
diff --git a/Robust.Client/Console/Commands/Debug.cs b/Robust.Client/Console/Commands/Debug.cs
index 176247149..caf3dc7e5 100644
--- a/Robust.Client/Console/Commands/Debug.cs
+++ b/Robust.Client/Console/Commands/Debug.cs
@@ -501,136 +501,6 @@ namespace Robust.Client.Console.Commands
}
}
- internal sealed class UITestCommand : LocalizedCommands
- {
- public override string Command => "uitest";
-
- public override void Execute(IConsoleShell shell, string argStr, string[] args)
- {
- var window = new DefaultWindow { MinSize = (500, 400) };
- var tabContainer = new TabContainer();
- window.Contents.AddChild(tabContainer);
- var scroll = new ScrollContainer();
- tabContainer.AddChild(scroll);
- //scroll.SetAnchorAndMarginPreset(Control.LayoutPreset.Wide);
- var vBox = new BoxContainer
- {
- Orientation = LayoutOrientation.Vertical
- };
- scroll.AddChild(vBox);
-
- var progressBar = new ProgressBar { MaxValue = 10, Value = 5 };
- vBox.AddChild(progressBar);
-
- var optionButton = new OptionButton();
- optionButton.AddItem("Honk");
- optionButton.AddItem("Foo");
- optionButton.AddItem("Bar");
- optionButton.AddItem("Baz");
- optionButton.OnItemSelected += eventArgs => optionButton.SelectId(eventArgs.Id);
- vBox.AddChild(optionButton);
-
- var tree = new Tree { VerticalExpand = true };
- var root = tree.CreateItem();
- root.Text = "Honk!";
- var child = tree.CreateItem();
- child.Text = "Foo";
- for (var i = 0; i < 20; i++)
- {
- child = tree.CreateItem();
- child.Text = $"Bar {i}";
- }
-
- vBox.AddChild(tree);
-
- var rich = new RichTextLabel();
- var message = new FormattedMessage();
- message.AddText("Foo\n");
- message.PushColor(Color.Red);
- message.AddText("Bar");
- message.Pop();
- rich.SetMessage(message);
- vBox.AddChild(rich);
-
- var itemList = new ItemList();
- tabContainer.AddChild(itemList);
- for (var i = 0; i < 10; i++)
- {
- itemList.AddItem(i.ToString());
- }
-
- var grid = new GridContainer { Columns = 3 };
- tabContainer.AddChild(grid);
- for (var y = 0; y < 3; y++)
- {
- for (var x = 0; x < 3; x++)
- {
- grid.AddChild(new Button
- {
- MinSize = (50, 50),
- Text = $"{x}, {y}"
- });
- }
- }
-
- var group = new ButtonGroup();
- var vBoxRadioButtons = new BoxContainer
- {
- Orientation = LayoutOrientation.Vertical
- };
- for (var i = 0; i < 10; i++)
- {
- vBoxRadioButtons.AddChild(new Button
- {
- Text = i.ToString(),
- Group = group
- });
-
- // ftftftftftftft
- }
-
- tabContainer.AddChild(vBoxRadioButtons);
-
- TabContainer.SetTabTitle(vBoxRadioButtons, "Radio buttons!!");
-
- tabContainer.AddChild(new BoxContainer
- {
- Orientation = LayoutOrientation.Vertical,
- Name = "Slider",
- Children =
- {
- new Slider()
- }
- });
-
- tabContainer.AddChild(new SplitContainer
- {
- Orientation = SplitContainer.SplitOrientation.Horizontal,
- Children =
- {
- new PanelContainer
- {
- PanelOverride = new StyleBoxFlat { BackgroundColor = Color.Red },
- Children =
- {
- new Label { Text = "FOOBARBAZ" },
- }
- },
- new PanelContainer
- {
- PanelOverride = new StyleBoxFlat { BackgroundColor = Color.Blue },
- Children =
- {
- new Label { Text = "FOOBARBAZ" },
- }
- },
- }
- });
-
- window.OpenCentered();
- }
- }
-
internal sealed class SetClipboardCommand : LocalizedCommands
{
public override string Command => "setclipboard";
diff --git a/Robust.Client/Console/Commands/UITestCommand.cs b/Robust.Client/Console/Commands/UITestCommand.cs
new file mode 100644
index 000000000..d832fc7af
--- /dev/null
+++ b/Robust.Client/Console/Commands/UITestCommand.cs
@@ -0,0 +1,285 @@
+using System;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Shared.Console;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+using Robust.Shared.Maths;
+using Robust.Shared.Utility;
+
+namespace Robust.Client.Console.Commands;
+
+internal sealed class UITestControl : Control
+{
+ private const string Lipsum = @"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sed interdum diam. Duis erat risus, tincidunt at pulvinar non, accumsan non dui. Morbi feugiat nisi in odio consectetur, ac suscipit nulla mollis. Nulla consequat neque sit amet neque venenatis feugiat. Proin placerat eget mauris sit amet tincidunt. Sed pulvinar purus sed ex varius, et lobortis risus efficitur. Integer blandit eu neque quis elementum. Vivamus lacinia sem non lacinia eleifend. Integer sit amet est ac risus tempus iaculis sed quis leo. Proin eu dui tincidunt orci ornare elementum. Curabitur molestie enim scelerisque, porttitor ipsum vitae, posuere libero. Donec finibus placerat accumsan. Nam et arcu lacus.
+
+Proin sed dui gravida nibh faucibus sodales ut sit amet dolor. Pellentesque ornare neque ac ante sagittis posuere. Maecenas ullamcorper pellentesque aliquet. Vestibulum ipsum ipsum, hendrerit eu venenatis eget, tempor aliquet ex. Etiam sed nunc eu orci condimentum consequat. Praesent commodo sem a lorem consequat, nec vestibulum elit dignissim. Sed fermentum maximus neque, non vestibulum felis. Quisque vulputate vehicula massa, sit amet accumsan purus condimentum nec. Ut tincidunt in purus sit amet lobortis. Nunc et eros vel elit sodales mollis. Aenean facilisis justo libero, at mollis arcu rutrum eget. Aenean rutrum, orci pretium faucibus auctor, tellus quam tincidunt diam, et feugiat turpis lectus nec sem.
+
+Donec et ipsum urna. Vestibulum consequat risus vitae orci consectetur ornare id id ligula. Donec ac nunc venenatis, volutpat elit eget, eleifend ex. Fusce eget odio sed tortor luctus feugiat. Maecenas lobortis nulla sit amet nisl egestas vulputate. Aliquam a placerat nunc. Fusce porta ultricies tortor, vitae dictum elit aliquet ac. In massa sapien, lobortis laoreet odio dignissim, congue blandit nibh. Quisque et iaculis eros, sed pretium felis. Praesent venenatis porta odio sed vulputate. Vivamus lacus nulla, lacinia non commodo id, ultricies nec arcu. Donec scelerisque pretium mollis. Etiam eu facilisis leo.
+
+Curabitur vulputate euismod massa, pulvinar tincidunt arcu vestibulum ut. Sed eu tempus velit, at porttitor justo. In eget turpis fermentum nibh euismod vestibulum. Proin vitae malesuada ipsum. Nunc at aliquet erat, sed maximus tortor. Cras tristique consequat elit, ut venenatis elit feugiat et. In malesuada, erat a tempus vehicula, nulla justo efficitur mauris, vitae ornare lectus massa eu sapien. Nam libero diam, gravida ac dapibus sed, hendrerit sed libero. Sed fringilla enim vel elit finibus congue. Fusce tristique, neque sit amet blandit posuere, ex urna malesuada ligula, ut sodales dolor est vitae lectus. Sed pharetra tincidunt pulvinar. Fusce sit amet finibus nulla, vel maximus tellus. Etiam in nisl ex. Fusce tempus augue lectus, eu sagittis arcu tempor id. Sed feugiat venenatis semper. Cras eget mollis nisi.
+
+Suspendisse hendrerit blandit urna ut laoreet. Suspendisse ac elit at erat malesuada commodo id vel dolor. Etiam sem magna, placerat lobortis mattis a, tincidunt at nisi. Ut gravida arcu purus, eu feugiat turpis accumsan non. Sed sit amet varius enim, sed ornare ante. Integer porta felis felis. Vestibulum euismod velit sit amet eleifend posuere. Cras laoreet fermentum condimentum. Suspendisse potenti. Donec iaculis sodales vestibulum. Etiam quis dictum nisl. Fusce dui ex, viverra nec lacus sed, tincidunt accumsan odio. Nulla sit amet ipsum eros. Curabitur et lectus ut nisi lobortis sollicitudin a eu turpis. Etiam molestie purus vitae porttitor auctor.
+";
+
+
+ private readonly TabContainer _tabContainer;
+
+ public UITestControl()
+ {
+ _tabContainer = new TabContainer();
+ AddChild(_tabContainer);
+ var scroll = new ScrollContainer();
+ _tabContainer.AddChild(scroll);
+ //scroll.SetAnchorAndMarginPreset(Control.LayoutPreset.Wide);
+ var vBox = new BoxContainer
+ {
+ Orientation = BoxContainer.LayoutOrientation.Vertical
+ };
+ scroll.AddChild(vBox);
+
+ var progressBar = new ProgressBar { MaxValue = 10, Value = 5 };
+ vBox.AddChild(progressBar);
+
+ var optionButton = new OptionButton();
+ optionButton.AddItem("Honk");
+ optionButton.AddItem("Foo");
+ optionButton.AddItem("Bar");
+ optionButton.AddItem("Baz");
+ optionButton.OnItemSelected += eventArgs => optionButton.SelectId(eventArgs.Id);
+ vBox.AddChild(optionButton);
+
+ var tree = new Tree { VerticalExpand = true };
+ var root = tree.CreateItem();
+ root.Text = "Honk!";
+ var child = tree.CreateItem();
+ child.Text = "Foo";
+ for (var i = 0; i < 20; i++)
+ {
+ child = tree.CreateItem();
+ child.Text = $"Bar {i}";
+ }
+
+ vBox.AddChild(tree);
+
+ var rich = new RichTextLabel();
+ var message = new FormattedMessage();
+ message.AddText("Foo\n");
+ message.PushColor(Color.Red);
+ message.AddText("Bar");
+ message.Pop();
+ rich.SetMessage(message);
+ vBox.AddChild(rich);
+
+ var itemList = new ItemList();
+ _tabContainer.AddChild(itemList);
+ for (var i = 0; i < 10; i++)
+ {
+ itemList.AddItem(i.ToString());
+ }
+
+ var grid = new GridContainer { Columns = 3 };
+ _tabContainer.AddChild(grid);
+ for (var y = 0; y < 3; y++)
+ {
+ for (var x = 0; x < 3; x++)
+ {
+ grid.AddChild(new Button
+ {
+ MinSize = (50, 50),
+ Text = $"{x}, {y}"
+ });
+ }
+ }
+
+ var group = new ButtonGroup();
+ var vBoxRadioButtons = new BoxContainer
+ {
+ Orientation = BoxContainer.LayoutOrientation.Vertical
+ };
+ for (var i = 0; i < 10; i++)
+ {
+ vBoxRadioButtons.AddChild(new Button
+ {
+ Text = i.ToString(),
+ Group = group
+ });
+
+ // ftftftftftftft
+ }
+
+ _tabContainer.AddChild(vBoxRadioButtons);
+
+ TabContainer.SetTabTitle(vBoxRadioButtons, "Radio buttons!!");
+
+ _tabContainer.AddChild(new BoxContainer
+ {
+ Orientation = BoxContainer.LayoutOrientation.Vertical,
+ Name = "Slider",
+ Children =
+ {
+ new Slider()
+ }
+ });
+
+ _tabContainer.AddChild(new SplitContainer
+ {
+ Orientation = SplitContainer.SplitOrientation.Horizontal,
+ Children =
+ {
+ new PanelContainer
+ {
+ PanelOverride = new StyleBoxFlat { BackgroundColor = Color.Red },
+ Children =
+ {
+ new Label { Text = "FOOBARBAZ" },
+ }
+ },
+ new PanelContainer
+ {
+ PanelOverride = new StyleBoxFlat { BackgroundColor = Color.Blue },
+ Children =
+ {
+ new Label { Text = "FOOBARBAZ" },
+ }
+ },
+ }
+ });
+
+ _tabContainer.AddChild(TabTextEdit());
+ _tabContainer.AddChild(TabRichText());
+ }
+
+ private Control TabTextEdit()
+ {
+ var textEdit = new TextEdit
+ {
+ Placeholder = new Rope.Leaf("You deleted the lipsum OwO")
+ };
+ TabContainer.SetTabTitle(textEdit, "TextEdit");
+
+ var lipsumRope = new Rope.Branch(Rope.Leaf.Empty, null);
+
+ var startIndex = 0;
+ while (true)
+ {
+ var nextIndex = Lipsum.IndexOf(' ', startIndex);
+ var str = nextIndex == -1 ? Lipsum[startIndex..] : Lipsum[startIndex..(nextIndex+1)];
+
+ lipsumRope = new Rope.Branch(lipsumRope, new Rope.Leaf(str));
+ if (lipsumRope.Depth > 250)
+ lipsumRope = (Rope.Branch)Rope.Rebalance(lipsumRope);
+
+ if (nextIndex == -1)
+ break;
+
+ startIndex = nextIndex + 1;
+ }
+
+ var rope = new Rope.Branch(lipsumRope, null);
+
+ for (var i = 0; i < 10; i++)
+ {
+ rope = new Rope.Branch(rope, lipsumRope);
+ }
+
+ rope = (Rope.Branch) Rope.Rebalance(rope);
+
+ textEdit.TextRope = rope;
+
+ return textEdit;
+ }
+
+ private Control TabRichText()
+ {
+ var label = new RichTextLabel();
+ label.SetMessage(FormattedMessage.FromMarkup(Lipsum));
+
+ TabContainer.SetTabTitle(label, "RichText");
+ return label;
+ }
+
+ public void SelectTab(Tab tab)
+ {
+ _tabContainer.CurrentTab = (int)tab;
+ }
+
+ public enum Tab : byte
+ {
+ Untitled1 = 0,
+ Untitled2 = 1,
+ Untitled3 = 2,
+ RadioButtons = 3,
+ Slider = 4,
+ Untitled4 = 5,
+ TextEdit = 6,
+ RichText = 7,
+ }
+}
+
+internal sealed class UITestCommand : LocalizedCommands
+{
+ public override string Command => "uitest";
+
+ public override void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ var window = new DefaultWindow { MinSize = (500, 400) };
+ window.Contents.AddChild(new UITestControl());
+
+ window.OpenCentered();
+ }
+}
+
+internal sealed class UITest2Command : LocalizedCommands
+{
+ [Dependency] private readonly IClyde _clyde = default!;
+ [Dependency] private readonly IUserInterfaceManager _uiMgr = default!;
+
+ public override string Command => "uitest2";
+
+ public override void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (args.Length > 1)
+ {
+ shell.WriteError(Loc.GetString("cmd-uitest2-error-args"));
+ return;
+ }
+
+ var control = new UITestControl();
+
+ if (args.Length == 1)
+ {
+ if (!Enum.TryParse(args[0], out UITestControl.Tab tab))
+ {
+ shell.WriteError(Loc.GetString("cmd-uitest2-error-tab", ("value", args[0])));
+ return;
+ }
+
+ control.SelectTab(tab);
+ }
+
+ var window = _clyde.CreateWindow(new WindowCreateParameters
+ {
+ Title = Loc.GetString("cmd-uitest2-title"),
+ });
+
+ var root = _uiMgr.CreateWindowRoot(window);
+ window.DisposeOnClose = true;
+
+ root.AddChild(control);
+ }
+
+ public override CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+ {
+ if (args.Length == 1)
+ {
+ return CompletionResult.FromHintOptions(
+ Enum.GetNames(),
+ Loc.GetString("cmd-uitest2-arg-tab"));
+ }
+
+ return CompletionResult.Empty;
+ }
+}
diff --git a/Robust.Client/GameController/GameController.Input.cs b/Robust.Client/GameController/GameController.Input.cs
index 2bd1037bb..a6bc6a0cb 100644
--- a/Robust.Client/GameController/GameController.Input.cs
+++ b/Robust.Client/GameController/GameController.Input.cs
@@ -20,9 +20,14 @@ namespace Robust.Client
_inputManager.KeyUp(keyEvent);
}
- public void TextEntered(TextEventArgs textEvent)
+ public void TextEntered(TextEnteredEventArgs textEnteredEvent)
{
- _userInterfaceManager.TextEntered(textEvent);
+ _userInterfaceManager.TextEntered(textEnteredEvent);
+ }
+
+ public void TextEditing(TextEditingEventArgs textEvent)
+ {
+ _userInterfaceManager.TextEditing(textEvent);
}
///
diff --git a/Robust.Client/GameController/GameController.cs b/Robust.Client/GameController/GameController.cs
index 0be92bdd8..30d5c848b 100644
--- a/Robust.Client/GameController/GameController.cs
+++ b/Robust.Client/GameController/GameController.cs
@@ -383,6 +383,7 @@ namespace Robust.Client
}
_clyde.TextEntered += TextEntered;
+ _clyde.TextEditing += TextEditing;
_clyde.MouseMove += MouseMove;
_clyde.KeyUp += KeyUp;
_clyde.KeyDown += KeyDown;
diff --git a/Robust.Client/Graphics/Clyde/Clyde.Events.cs b/Robust.Client/Graphics/Clyde/Clyde.Events.cs
index b70ef42cc..4421c8f47 100644
--- a/Robust.Client/Graphics/Clyde/Clyde.Events.cs
+++ b/Robust.Client/Graphics/Clyde/Clyde.Events.cs
@@ -61,6 +61,9 @@ namespace Robust.Client.Graphics.Clyde
case DEventText(var args):
TextEntered?.Invoke(args);
break;
+ case DEventTextEditing(var args):
+ TextEditing?.Invoke(args);
+ break;
case DEventWindowClosed(var reg, var args):
reg.RequestClosed?.Invoke(args);
CloseWindow?.Invoke(args);
@@ -126,11 +129,16 @@ namespace Robust.Client.Graphics.Clyde
_eventDispatchQueue.Enqueue(new DEventWindowFocus(ev));
}
- private void SendText(TextEventArgs ev)
+ private void SendText(TextEnteredEventArgs ev)
{
_eventDispatchQueue.Enqueue(new DEventText(ev));
}
+ private void SendTextEditing(TextEditingEventArgs ev)
+ {
+ _eventDispatchQueue.Enqueue(new DEventTextEditing(ev));
+ }
+
private void SendMouseMove(MouseMoveEventArgs ev)
{
_eventDispatchQueue.Enqueue(new DEventMouseMove(ev));
@@ -158,7 +166,8 @@ namespace Robust.Client.Graphics.Clyde
private sealed record DEventWindowFocus(WindowFocusedEventArgs Args) : DEventBase;
- private sealed record DEventText(TextEventArgs Args) : DEventBase;
+ private sealed record DEventText(TextEnteredEventArgs Args) : DEventBase;
+ private sealed record DEventTextEditing(TextEditingEventArgs Args) : DEventBase;
private sealed record DEventMouseMove(MouseMoveEventArgs Args) : DEventBase;
private sealed record DEventMouseEnterLeave(MouseEnterLeaveEventArgs Args) : DEventBase;
diff --git a/Robust.Client/Graphics/Clyde/Clyde.Windowing.cs b/Robust.Client/Graphics/Clyde/Clyde.Windowing.cs
index ee440eeb7..ee35984b1 100644
--- a/Robust.Client/Graphics/Clyde/Clyde.Windowing.cs
+++ b/Robust.Client/Graphics/Clyde/Clyde.Windowing.cs
@@ -41,7 +41,8 @@ namespace Robust.Client.Graphics.Clyde
private bool _threadWindowBlit;
private bool EffectiveThreadWindowBlit => _threadWindowBlit && !_isGLES;
- public event Action? TextEntered;
+ public event Action? TextEntered;
+ public event Action? TextEditing;
public event Action? MouseMove;
public event Action? MouseEnterLeave;
public event Action? KeyUp;
@@ -465,6 +466,27 @@ namespace Robust.Client.Graphics.Clyde
_windowing!.RunOnWindowThread(a);
}
+ public void TextInputSetRect(UIBox2i rect)
+ {
+ DebugTools.AssertNotNull(_windowing);
+
+ _windowing!.TextInputSetRect(rect);
+ }
+
+ public void TextInputStart()
+ {
+ DebugTools.AssertNotNull(_windowing);
+
+ _windowing!.TextInputStart();
+ }
+
+ public void TextInputStop()
+ {
+ DebugTools.AssertNotNull(_windowing);
+
+ _windowing!.TextInputStop();
+ }
+
private abstract class WindowReg
{
public bool IsDisposed;
diff --git a/Robust.Client/Graphics/Clyde/ClydeHeadless.cs b/Robust.Client/Graphics/Clyde/ClydeHeadless.cs
index dc520e6e4..15f5d88a2 100644
--- a/Robust.Client/Graphics/Clyde/ClydeHeadless.cs
+++ b/Robust.Client/Graphics/Clyde/ClydeHeadless.cs
@@ -50,7 +50,8 @@ namespace Robust.Client.Graphics.Clyde
public IClydeDebugInfo DebugInfo { get; } = new DummyDebugInfo();
public IClydeDebugStats DebugStats { get; } = new DummyDebugStats();
- public event Action? TextEntered { add { } remove { } }
+ public event Action? TextEntered { add { } remove { } }
+ public event Action? TextEditing { add { } remove { } }
public event Action? MouseMove { add { } remove { } }
public event Action? MouseEnterLeave { add { } remove { } }
public event Action? KeyUp { add { } remove { } }
@@ -228,6 +229,21 @@ namespace Robust.Client.Graphics.Clyde
return window;
}
+ public void TextInputSetRect(UIBox2i rect)
+ {
+ // Nada.
+ }
+
+ public void TextInputStart()
+ {
+ // Nada.
+ }
+
+ public void TextInputStop()
+ {
+ // Nada.
+ }
+
public ClydeHandle LoadShader(ParsedShader shader, string? name = null, Dictionary? defines = null)
{
return default;
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Glfw.Events.cs b/Robust.Client/Graphics/Clyde/Windowing/Glfw.Events.cs
index c25cc0d7f..2f46ce4ed 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Glfw.Events.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Glfw.Events.cs
@@ -1,4 +1,5 @@
using System;
+using System.Text;
using OpenToolkit.GraphicsLibraryFramework;
using Robust.Client.Input;
using Robust.Shared.Map;
@@ -94,7 +95,10 @@ namespace Robust.Client.Graphics.Clyde
private void ProcessEventChar(EventChar ev)
{
- _clyde.SendText(new TextEventArgs(ev.CodePoint));
+ if (!_textInputActive)
+ return;
+
+ _clyde.SendText(new TextEnteredEventArgs(new Rune(ev.CodePoint).ToString()));
}
private void ProcessEventCursorPos(EventCursorPos ev)
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Glfw.Windows.cs b/Robust.Client/Graphics/Clyde/Windowing/Glfw.Windows.cs
index 5beebea97..cb8170133 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Glfw.Windows.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Glfw.Windows.cs
@@ -668,6 +668,25 @@ namespace Robust.Client.Graphics.Clyde
return (void*) GLFW.GetProcAddress(procName);
}
+ public void TextInputSetRect(UIBox2i rect)
+ {
+ // Not supported on GLFW.
+ }
+
+ public void TextInputStart()
+ {
+ // Not properly supported on GLFW.
+
+ _textInputActive = true;
+ }
+
+ public void TextInputStop()
+ {
+ // Not properly supported on GLFW.
+
+ _textInputActive = false;
+ }
+
private void CheckWindowDisposed(WindowReg reg)
{
if (reg.IsDisposed)
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Glfw.cs b/Robust.Client/Graphics/Clyde/Windowing/Glfw.cs
index 73e297773..12a8c22bc 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Glfw.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Glfw.cs
@@ -27,6 +27,10 @@ namespace Robust.Client.Graphics.Clyde
private bool _glfwInitialized;
private bool _win32Experience;
+ // While GLFW does not provide proper IME APIs, we can at least emulate SDL2's StartTextInput() system.
+ // This will ensure some level of consistency between the backends.
+ private bool _textInputActive;
+
public GlfwWindowingImpl(Clyde clyde)
{
_clyde = clyde;
diff --git a/Robust.Client/Graphics/Clyde/Windowing/IWindowingImpl.cs b/Robust.Client/Graphics/Clyde/Windowing/IWindowingImpl.cs
index 87973bfd4..6131e1b74 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/IWindowingImpl.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/IWindowingImpl.cs
@@ -62,6 +62,11 @@ namespace Robust.Client.Graphics.Clyde
// Misc
void RunOnWindowThread(Action a);
+
+ // IME
+ void TextInputSetRect(UIBox2i rect);
+ void TextInputStart();
+ void TextInputStop();
}
}
}
diff --git a/Robust.Client/Graphics/Clyde/Windowing/SDL2Api.cs b/Robust.Client/Graphics/Clyde/Windowing/SDL2Api.cs
index caec77b66..aacb2611f 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/SDL2Api.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/SDL2Api.cs
@@ -37,7 +37,7 @@ using System.Text;
namespace SDL2
{
- public static class SDL
+ internal static unsafe class SDL
{
#region SDL2# Variables
@@ -1152,6 +1152,12 @@ namespace SDL2
IntPtr userdata
);
+ [DllImport(nativeLibName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern void SDL_LogSetOutputFunction(
+ delegate* unmanaged[Cdecl] callback,
+ void* userdata
+ );
+
#endregion
#region SDL_messagebox.h
@@ -5540,6 +5546,12 @@ namespace SDL2
IntPtr userdata
);
+ [DllImport(nativeLibName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern void SDL_AddEventWatch(
+ delegate* unmanaged[Cdecl] filter,
+ void* userdata
+ );
+
/* userdata refers to a void* */
[DllImport(nativeLibName, CallingConvention = CallingConvention.Cdecl)]
public static extern void SDL_DelEventWatch(
@@ -5547,6 +5559,12 @@ namespace SDL2
IntPtr userdata
);
+ [DllImport(nativeLibName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern void SDL_DelEventWatch(
+ delegate* unmanaged[Cdecl] filter,
+ void* userdata
+ );
+
/* userdata refers to a void* */
[DllImport(nativeLibName, CallingConvention = CallingConvention.Cdecl)]
public static extern void SDL_FilterEvents(
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.Events.cs b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.Events.cs
index 6f4344fce..e39d8d920 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.Events.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.Events.cs
@@ -2,6 +2,7 @@
using Robust.Client.Input;
using Robust.Shared.Map;
using Robust.Shared.Maths;
+using TerraFX.Interop.Windows;
using static SDL2.SDL;
using static SDL2.SDL.SDL_EventType;
using static SDL2.SDL.SDL_Keymod;
@@ -58,6 +59,9 @@ internal partial class Clyde
case EventText ev:
ProcessEventText(ev);
break;
+ case EventTextEditing ev:
+ ProcessEventTextEditing(ev);
+ break;
case EventMouseMotion ev:
ProcessEventMouseMotion(ev);
break;
@@ -70,6 +74,9 @@ internal partial class Clyde
case EventMonitorSetup ev:
ProcessSetupMonitor(ev);
break;
+ case EventWindowsFakeV ev:
+ ProcessWindowsFakeV(ev);
+ break;
default:
_sawmill.Error($"Unknown SDL2 event type: {evb.GetType().Name}");
break;
@@ -157,10 +164,12 @@ internal partial class Clyde
private void ProcessEventText(EventText ev)
{
- foreach (var rune in ev.Text.EnumerateRunes())
- {
- _clyde.SendText(new TextEventArgs((uint) rune.Value));
- }
+ _clyde.SendText(new TextEnteredEventArgs(ev.Text));
+ }
+
+ private void ProcessEventTextEditing(EventTextEditing ev)
+ {
+ _clyde.SendTextEditing(new TextEditingEventArgs(ev.Text, ev.Start, ev.Length));
}
private void ProcessEventWindowSize(EventWindowSize ev)
@@ -182,7 +191,7 @@ internal partial class Clyde
if (fbW == 0 || fbH == 0 || width == 0 || height == 0)
return;
- windowReg.PixelRatio = windowReg.FramebufferSize / (Vector2) windowReg.WindowSize;
+ windowReg.PixelRatio = windowReg.FramebufferSize / (Vector2)windowReg.WindowSize;
if (windowReg.WindowScale != (ev.XScale, ev.YScale))
{
@@ -223,5 +232,24 @@ internal partial class Clyde
break;
}
}
+
+ private void ProcessWindowsFakeV(EventWindowsFakeV ev)
+ {
+ var type = (int)ev.Message switch
+ {
+ WM.WM_KEYUP => SDL_KEYUP,
+ WM.WM_KEYDOWN => SDL_KEYDOWN,
+ _ => throw new ArgumentOutOfRangeException()
+ };
+
+ var key = (int)ev.WParam switch
+ {
+ 0x56 /* V */ => Key.V,
+ VK.VK_CONTROL => Key.Control,
+ _ => throw new ArgumentOutOfRangeException()
+ };
+
+ EmitKeyEvent(key, type, false, 0, 0);
+ }
}
}
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.RawEvents.cs b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.RawEvents.cs
index 25dc17dae..4dd28e366 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.RawEvents.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.RawEvents.cs
@@ -1,8 +1,12 @@
using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
+using TerraFX.Interop.Windows;
using static SDL2.SDL;
using static SDL2.SDL.SDL_EventType;
+using static SDL2.SDL.SDL_SYSWM_TYPE;
using static SDL2.SDL.SDL_WindowEventID;
namespace Robust.Client.Graphics.Clyde;
@@ -11,20 +15,13 @@ internal partial class Clyde
{
private sealed partial class Sdl2WindowingImpl
{
- private SDL_LogOutputFunction? _logOutputFunction;
- private SDL_EventFilter? _eventWatch;
-
- private void StoreCallbacks()
+ [UnmanagedCallersOnly(CallConvs = new []{typeof(CallConvCdecl)})]
+ private static unsafe int EventWatch(void* userdata, SDL_Event* sdlevent)
{
- _logOutputFunction = LogOutputFunction;
- _eventWatch = EventWatch;
- }
+ var obj = (Sdl2WindowingImpl) GCHandle.FromIntPtr((IntPtr)userdata).Target!;
+ ref readonly var ev = ref *sdlevent;
- private unsafe int EventWatch(IntPtr userdata, IntPtr sdlevent)
- {
- ref readonly var ev = ref *(SDL_Event*)sdlevent;
-
- ProcessSdl2Event(in ev);
+ obj.ProcessSdl2Event(in ev);
return 0;
}
@@ -34,32 +31,41 @@ internal partial class Clyde
switch (ev.type)
{
case SDL_WINDOWEVENT:
- ProcessSdl2EventWindow(ev.window);
+ ProcessSdl2EventWindow(in ev.window);
break;
case SDL_KEYDOWN:
case SDL_KEYUP:
- ProcessSdl2KeyEvent(ev.key);
+ ProcessSdl2KeyEvent(in ev.key);
break;
case SDL_TEXTINPUT:
- ProcessSdl2EventTextInput(ev.text);
+ ProcessSdl2EventTextInput(in ev.text);
+ break;
+ case SDL_TEXTEDITING:
+ ProcessSdl2EventTextEditing(in ev.edit);
+ break;
+ case SDL_TEXTEDITING_EXT:
+ ProcessSdl2EventTextEditingExt(in ev.editExt);
break;
case SDL_MOUSEMOTION:
- ProcessSdl2EventMouseMotion(ev.motion);
+ ProcessSdl2EventMouseMotion(in ev.motion);
break;
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP:
- ProcessSdl2EventMouseButton(ev.button);
+ ProcessSdl2EventMouseButton(in ev.button);
break;
case SDL_MOUSEWHEEL:
- ProcessSdl2EventMouseWheel(ev.wheel);
+ ProcessSdl2EventMouseWheel(in ev.wheel);
break;
case SDL_DISPLAYEVENT:
- ProcessSdl2EventDisplay(ev.display);
+ ProcessSdl2EventDisplay(in ev.display);
+ break;
+ case SDL_SYSWMEVENT:
+ ProcessSdl2EventSysWM(in ev.syswm);
break;
}
}
- private void ProcessSdl2EventDisplay(SDL_DisplayEvent evDisplay)
+ private void ProcessSdl2EventDisplay(in SDL_DisplayEvent evDisplay)
{
switch (evDisplay.displayEvent)
{
@@ -93,10 +99,32 @@ internal partial class Clyde
fixed (byte* text = ev.text)
{
var str = Marshal.PtrToStringUTF8((IntPtr)text) ?? "";
+ // _logManager.GetSawmill("ime").Debug($"Input: {str}");
SendEvent(new EventText(ev.windowID, str));
}
}
+ private unsafe void ProcessSdl2EventTextEditing(in SDL_TextEditingEvent ev)
+ {
+ fixed (byte* text = ev.text)
+ {
+ SendTextEditing(ev.windowID, text, ev.start, ev.length);
+ }
+ }
+
+ private unsafe void ProcessSdl2EventTextEditingExt(in SDL_TextEditingExtEvent ev)
+ {
+ SendTextEditing(ev.windowID, (byte*) ev.text, ev.start, ev.length);
+ SDL_free(ev.text);
+ }
+
+ private unsafe void SendTextEditing(uint window, byte* text, int start, int length)
+ {
+ var str = Marshal.PtrToStringUTF8((nint) text) ?? "";
+ // _logManager.GetSawmill("ime").Debug($"Editing: '{str}', start: {start}, len: {length}");
+ SendEvent(new EventTextEditing(window, str, start, length));
+ }
+
private void ProcessSdl2KeyEvent(in SDL_KeyboardEvent ev)
{
SendEvent(new EventKey(
@@ -130,6 +158,37 @@ internal partial class Clyde
}
}
+ // ReSharper disable once InconsistentNaming
+ private unsafe void ProcessSdl2EventSysWM(in SDL_SysWMEvent ev)
+ {
+ ref readonly var sysWmMessage = ref *(SDL_SysWMmsg*)ev.msg;
+ if (sysWmMessage.subsystem != SDL_SYSWM_WINDOWS)
+ return;
+
+ ref readonly var winMessage = ref *(SDL_SysWMmsgWin32*)ev.msg;
+ if (winMessage.msg is WM.WM_KEYDOWN or WM.WM_KEYUP)
+ {
+ TryWin32VirtualVKey(in winMessage);
+ }
+ }
+
+ private void TryWin32VirtualVKey(in SDL_SysWMmsgWin32 msg)
+ {
+ // Workaround for https://github.com/ocornut/imgui/issues/2977
+ // This is gonna bite me in the ass if SDL2 ever fixes this upstream, isn't it...
+ // (I spent disproportionate amounts of effort on this).
+
+ // Code for V key.
+ if ((int)msg.wParam is not (0x56 or VK.VK_CONTROL))
+ return;
+
+ var scanCode = (msg.lParam >> 16) & 0xFF;
+ if (scanCode != 0)
+ return;
+
+ SendEvent(new EventWindowsFakeV(msg.hwnd, msg.msg, msg.wParam));
+ }
+
private abstract record EventBase;
private record EventWindowCreate(
@@ -162,6 +221,13 @@ internal partial class Clyde
string Text
) : EventBase;
+ private record EventTextEditing(
+ uint WindowId,
+ string Text,
+ int Start,
+ int Length
+ ) : EventBase;
+
private record EventWindowSize(
uint WindowId,
int Width,
@@ -196,5 +262,34 @@ internal partial class Clyde
(
int Id
) : EventBase;
+
+ private record EventWindowsFakeV(HWND Window,
+ uint Message, WPARAM WParam) : EventBase;
+
+ [StructLayout(LayoutKind.Sequential)]
+ [SuppressMessage("ReSharper", "InconsistentNaming")]
+ [SuppressMessage("ReSharper", "IdentifierTypo")]
+ [SuppressMessage("ReSharper", "FieldCanBeMadeReadOnly.Local")]
+ [SuppressMessage("ReSharper", "MemberCanBePrivate.Local")]
+ private struct SDL_SysWMmsg
+ {
+ public SDL_version version;
+ public SDL_SYSWM_TYPE subsystem;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ [SuppressMessage("ReSharper", "InconsistentNaming")]
+ [SuppressMessage("ReSharper", "IdentifierTypo")]
+ [SuppressMessage("ReSharper", "FieldCanBeMadeReadOnly.Local")]
+ [SuppressMessage("ReSharper", "MemberCanBePrivate.Local")]
+ private struct SDL_SysWMmsgWin32
+ {
+ public SDL_version version;
+ public SDL_SYSWM_TYPE subsystem;
+ public HWND hwnd;
+ public uint msg;
+ public WPARAM wParam;
+ public LPARAM lParam;
+ }
}
}
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.WindowThread.cs b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.WindowThread.cs
index 4075e0139..74a6252ce 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.WindowThread.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.WindowThread.cs
@@ -104,6 +104,18 @@ internal partial class Clyde
case CmdWinWinSetMode cmd:
WinThreadWinSetMode(cmd);
break;
+
+ case CmdTextInputSetRect cmd:
+ WinThreadSetTextInputRect(cmd);
+ break;
+
+ case CmdTextInputStart:
+ WinThreadStartTextInput();
+ break;
+
+ case CmdTextInputStop:
+ WinThreadStopTextInput();
+ break;
}
}
@@ -239,5 +251,20 @@ internal partial class Clyde
nint Window,
WindowMode Mode
) : CmdBase;
+
+ // IME
+ private sealed record CmdTextInputStart : CmdBase
+ {
+ public static readonly CmdTextInputStart Instance = new();
+ }
+
+ private sealed record CmdTextInputStop : CmdBase
+ {
+ public static readonly CmdTextInputStop Instance = new();
+ }
+
+ private sealed record CmdTextInputSetRect(
+ SDL_Rect Rect
+ ) : CmdBase;
}
}
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.Windows.cs b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.Windows.cs
index ecdc0f207..58f239702 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.Windows.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.Windows.cs
@@ -439,6 +439,43 @@ internal partial class Clyde
SendCmd(new CmdRunAction(a));
}
+ public void TextInputSetRect(UIBox2i rect)
+ {
+ SendCmd(new CmdTextInputSetRect(new SDL_Rect
+ {
+ x = rect.Left,
+ y = rect.Top,
+ w = rect.Width,
+ h = rect.Height
+ }));
+ }
+
+ private static void WinThreadSetTextInputRect(CmdTextInputSetRect cmdTextInput)
+ {
+ var rect = cmdTextInput.Rect;
+ SDL_SetTextInputRect(ref rect);
+ }
+
+ public void TextInputStart()
+ {
+ SendCmd(CmdTextInputStart.Instance);
+ }
+
+ private static void WinThreadStartTextInput()
+ {
+ SDL_StartTextInput();
+ }
+
+ public void TextInputStop()
+ {
+ SendCmd(CmdTextInputStop.Instance);
+ }
+
+ private static void WinThreadStopTextInput()
+ {
+ SDL_StopTextInput();
+ }
+
public void ClipboardSetText(WindowReg mainWindow, string text)
{
SendCmd(new CmdSetClipboard(text));
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.cs b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.cs
index b631f6e8e..65d03778f 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Sdl2.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Sdl2.cs
@@ -1,4 +1,5 @@
using System;
+using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Robust.Client.Input;
using Robust.Shared.Configuration;
@@ -6,6 +7,7 @@ using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Log;
using static SDL2.SDL;
+using DependencyAttribute = Robust.Shared.IoC.DependencyAttribute;
namespace Robust.Client.Graphics.Clyde;
@@ -18,6 +20,7 @@ internal partial class Clyde
[Dependency] private readonly ILocalizationManager _loc = default!;
private readonly Clyde _clyde;
+ private GCHandle _selfGCHandle;
private readonly ISawmill _sawmill;
private readonly ISawmill _sawmillSdl2;
@@ -41,14 +44,17 @@ internal partial class Clyde
return true;
}
- private bool InitSdl2()
+ private unsafe bool InitSdl2()
{
- StoreCallbacks();
+ _selfGCHandle = GCHandle.Alloc(this, GCHandleType.Normal);
SDL_LogSetAllPriority(SDL_LogPriority.SDL_LOG_PRIORITY_VERBOSE);
- SDL_LogSetOutputFunction(_logOutputFunction!, IntPtr.Zero);
+ SDL_LogSetOutputFunction(&LogOutputFunction, (void*) GCHandle.ToIntPtr(_selfGCHandle));
SDL_SetHint("SDL_WINDOWS_DPI_SCALING", "1");
+ SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1");
+ SDL_SetHint(SDL_HINT_IME_SUPPORT_EXTENDED_TEXT, "1");
+ SDL_SetHint(SDL_HINT_IME_SHOW_UI, "1");
var res = SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS);
if (res < 0)
@@ -63,17 +69,31 @@ internal partial class Clyde
_sdlEventWakeup = SDL_RegisterEvents(1);
+ SDL_EventState(SDL_EventType.SDL_SYSWMEVENT, SDL_ENABLE);
+
InitCursors();
InitMonitors();
InitKeyMap();
- SDL_AddEventWatch(_eventWatch, IntPtr.Zero);
+ SDL_AddEventWatch(&EventWatch, (void*) GCHandle.ToIntPtr(_selfGCHandle));
+
+ // SDL defaults to having text input enabled, so we have to manually turn it off in init for consistency.
+ // If we don't, text input will remain enabled *until* the user first leaves a LineEdit/TextEdit.
+ SDL_StopTextInput();
return true;
}
- public void Shutdown()
+ public unsafe void Shutdown()
{
+ if (_selfGCHandle != default)
+ {
+ SDL_DelEventWatch(&EventWatch, (void*) GCHandle.ToIntPtr(_selfGCHandle));
+ _selfGCHandle.Free();
+ }
+
+ SDL_LogSetOutputFunction(null, null);
+
if (SDL_WasInit(0) != 0)
{
_sawmill.Debug("Terminating SDL2");
@@ -109,8 +129,15 @@ internal partial class Clyde
return (void*) SDL_GL_GetProcAddress(procName);
}
- private void LogOutputFunction(IntPtr userdata, int category, SDL_LogPriority priority, IntPtr message)
+ [UnmanagedCallersOnly(CallConvs = new []{typeof(CallConvCdecl)})]
+ private static unsafe void LogOutputFunction(
+ void* userdata,
+ int category,
+ SDL_LogPriority priority,
+ byte* message)
{
+ var obj = (Sdl2WindowingImpl) GCHandle.FromIntPtr((IntPtr)userdata).Target!;
+
var level = priority switch
{
SDL_LogPriority.SDL_LOG_PRIORITY_VERBOSE => LogLevel.Verbose,
@@ -122,8 +149,8 @@ internal partial class Clyde
_ => LogLevel.Error
};
- var msg = Marshal.PtrToStringUTF8(message) ?? "";
- _sawmillSdl2.Log(level, msg);
+ var msg = Marshal.PtrToStringUTF8((IntPtr) message) ?? "";
+ obj._sawmillSdl2.Log(level, msg);
}
}
}
diff --git a/Robust.Client/Graphics/IClyde.cs b/Robust.Client/Graphics/IClyde.cs
index 3ed933bd9..55d5b606d 100644
--- a/Robust.Client/Graphics/IClyde.cs
+++ b/Robust.Client/Graphics/IClyde.cs
@@ -131,5 +131,29 @@ namespace Robust.Client.Graphics
IEnumerable EnumerateMonitors();
IClydeWindow CreateWindow(WindowCreateParameters parameters);
+
+ ///
+ /// Set the active text input area in window pixel coordinates.
+ ///
+ ///
+ /// This information is used by the OS to position overlays like IMEs or emoji pickers etc.
+ ///
+ void TextInputSetRect(UIBox2i rect);
+
+ ///
+ /// Indicate that the game should start accepting text input on the currently focused window.
+ ///
+ ///
+ /// On some platforms, this will cause an on-screen keyboard to appear.
+ /// The game will also start accepting IME input if configured by the user.
+ ///
+ ///
+ void TextInputStart();
+
+ ///
+ /// Stop text input, opposite of .
+ ///
+ ///
+ void TextInputStop();
}
}
diff --git a/Robust.Client/Graphics/IClydeInternal.cs b/Robust.Client/Graphics/IClydeInternal.cs
index ace25eb9c..d291f1449 100644
--- a/Robust.Client/Graphics/IClydeInternal.cs
+++ b/Robust.Client/Graphics/IClydeInternal.cs
@@ -23,7 +23,8 @@ namespace Robust.Client.Graphics
void Ready();
void TerminateWindowLoop();
- event Action TextEntered;
+ event Action TextEntered;
+ event Action TextEditing;
event Action MouseMove;
event Action MouseEnterLeave;
event Action KeyUp;
diff --git a/Robust.Client/IGameControllerInternal.cs b/Robust.Client/IGameControllerInternal.cs
index 7f16dac2f..cda00904e 100644
--- a/Robust.Client/IGameControllerInternal.cs
+++ b/Robust.Client/IGameControllerInternal.cs
@@ -13,7 +13,7 @@ namespace Robust.Client
void Run(GameController.DisplayMode mode, GameControllerOptions options, Func? logHandlerFactory = null);
void KeyDown(KeyEventArgs keyEvent);
void KeyUp(KeyEventArgs keyEvent);
- void TextEntered(TextEventArgs textEvent);
+ void TextEntered(TextEnteredEventArgs textEnteredEvent);
void MouseMove(MouseMoveEventArgs mouseMoveEventArgs);
void MouseWheel(MouseWheelEventArgs mouseWheelEventArgs);
void OverrideMainLoop(IGameLoop gameLoop);
diff --git a/Robust.Client/Input/EngineContexts.cs b/Robust.Client/Input/EngineContexts.cs
index be8fa1b44..ee15b5cf5 100644
--- a/Robust.Client/Input/EngineContexts.cs
+++ b/Robust.Client/Input/EngineContexts.cs
@@ -38,6 +38,8 @@ namespace Robust.Client.Input
common.AddFunction(EngineKeyFunctions.TextCursorLeft);
common.AddFunction(EngineKeyFunctions.TextCursorRight);
+ common.AddFunction(EngineKeyFunctions.TextCursorUp);
+ common.AddFunction(EngineKeyFunctions.TextCursorDown);
common.AddFunction(EngineKeyFunctions.TextCursorWordLeft);
common.AddFunction(EngineKeyFunctions.TextCursorWordRight);
common.AddFunction(EngineKeyFunctions.TextCursorBegin);
@@ -46,12 +48,15 @@ namespace Robust.Client.Input
common.AddFunction(EngineKeyFunctions.TextCursorSelect);
common.AddFunction(EngineKeyFunctions.TextCursorSelectLeft);
common.AddFunction(EngineKeyFunctions.TextCursorSelectRight);
+ common.AddFunction(EngineKeyFunctions.TextCursorSelectUp);
+ common.AddFunction(EngineKeyFunctions.TextCursorSelectDown);
common.AddFunction(EngineKeyFunctions.TextCursorSelectWordLeft);
common.AddFunction(EngineKeyFunctions.TextCursorSelectWordRight);
common.AddFunction(EngineKeyFunctions.TextCursorSelectBegin);
common.AddFunction(EngineKeyFunctions.TextCursorSelectEnd);
common.AddFunction(EngineKeyFunctions.TextBackspace);
+ common.AddFunction(EngineKeyFunctions.TextNewline);
common.AddFunction(EngineKeyFunctions.TextSubmit);
common.AddFunction(EngineKeyFunctions.TextCopy);
common.AddFunction(EngineKeyFunctions.TextCut);
diff --git a/Robust.Client/Input/Events.cs b/Robust.Client/Input/Events.cs
index 234312102..048aebaf7 100644
--- a/Robust.Client/Input/Events.cs
+++ b/Robust.Client/Input/Events.cs
@@ -53,16 +53,42 @@ namespace Robust.Client.Input
}
}
- [Virtual]
- public class TextEventArgs : EventArgs
- {
- public TextEventArgs(uint codePoint)
- {
- CodePoint = codePoint;
- }
+ ///
+ /// Information about text that has been typed by the user.
+ ///
+ /// The typed text.
+ public sealed record TextEnteredEventArgs(string Text);
- public uint CodePoint { get; }
- public Rune AsRune => new Rune(CodePoint);
+ ///
+ /// Information about an in-progress IME composition.
+ ///
+ ///
+ /// https://wiki.libsdl.org/Tutorials-TextInput
+ ///
+ ///
+ /// The position in the composition at which the cursor should be placed. This is in runes, not chars.
+ /// This is in runes, not chars.
+ public sealed record TextEditingEventArgs(string Text, int Start, int Length)
+ {
+ ///
+ /// Get but in chars instead of runes.
+ ///
+ public int GetStartChars()
+ {
+ var chars = 0;
+ var count = 0;
+
+ foreach (var rune in Text.EnumerateRunes())
+ {
+ if (count >= Start)
+ break;
+
+ count += 1;
+ chars += rune.Utf16SequenceLength;
+ }
+
+ return chars;
+ }
}
[Virtual]
diff --git a/Robust.Client/Input/InputManager.cs b/Robust.Client/Input/InputManager.cs
index 9f5978971..1d1c29bba 100644
--- a/Robust.Client/Input/InputManager.cs
+++ b/Robust.Client/Input/InputManager.cs
@@ -909,44 +909,34 @@ namespace Robust.Client.Input
[UsedImplicitly]
internal sealed class BindCommand : LocalizedCommands
{
+ [Dependency] private readonly IInputManager _inputManager = default!;
+
public override string Command => "bind";
public override void Execute(IConsoleShell shell, string argStr, string[] args)
{
- if (args.Length < 3)
+ if (args.Length != 3)
{
- shell.WriteLine("Too few arguments.");
- return;
- }
-
- if (args.Length > 3)
- {
- shell.WriteLine("Too many arguments.");
+ shell.WriteError(Loc.GetString("cmd-invalid-arg-number-error"));
return;
}
var keyName = args[0];
- if (!Enum.TryParse(typeof(Key), keyName, true, out var keyIdObj))
+ if (!Enum.TryParse(keyName, true, out var keyId))
{
shell.WriteLine($"Key '{keyName}' is unrecognized.");
return;
}
- var keyId = (Key) keyIdObj!;
-
- if (!Enum.TryParse(typeof(KeyBindingType), args[1], true, out var keyModeObj))
+ if (!Enum.TryParse(args[1], true, out var keyMode))
{
shell.WriteLine($"BindMode '{args[1]}' is unrecognized.");
return;
}
- var keyMode = (KeyBindingType) keyModeObj!;
-
var inputCommand = args[2];
- var inputMan = IoCManager.Resolve();
-
var registration = new KeyBindingRegistration
{
Function = inputCommand,
@@ -954,19 +944,52 @@ namespace Robust.Client.Input
Type = keyMode
};
- inputMan.RegisterBinding(registration);
+ _inputManager.RegisterBinding(registration);
+ }
+
+ public CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+ {
+ if (args.Length == 1)
+ {
+ var options = Enum.GetNames();
+ return CompletionResult.FromHintOptions(options, Loc.GetString("cmd-bind-arg-key"));
+ }
+
+ if (args.Length == 2)
+ {
+ var options = Enum.GetNames().Except(new []{nameof(KeyBindingType.Unknown)});
+ return CompletionResult.FromHintOptions(options, Loc.GetString("cmd-bind-arg-mode"));
+ }
+
+ if (!Enum.TryParse(args[1], true, out var type))
+ return CompletionResult.Empty;
+
+ if (args.Length == 3)
+ {
+ if (type == KeyBindingType.Command)
+ {
+ // Don't show completions for key functions if mode is command, it wouldn't make sense.
+ return CompletionResult.FromHint(Loc.GetString("cmd-bind-arg-command"));
+ }
+
+ var options = _inputManager.NetworkBindMap.AllKeyFunctions.Select(x => x.FunctionName);
+ return CompletionResult.FromHintOptions(options, Loc.GetString("cmd-bind-arg-command"));
+ }
+
+ return CompletionResult.Empty;
}
}
[UsedImplicitly]
internal sealed class SaveBindCommand : LocalizedCommands
{
+ [Dependency] private readonly IInputManager _inputManager = default!;
+
public override string Command => "svbind";
public override void Execute(IConsoleShell shell, string argStr, string[] args)
{
- IoCManager.Resolve()
- .SaveToUserData();
+ _inputManager.SaveToUserData();
}
}
}
diff --git a/Robust.Client/UserInterface/Control.Input.cs b/Robust.Client/UserInterface/Control.Input.cs
index e63d81b75..417afaf6e 100644
--- a/Robust.Client/UserInterface/Control.Input.cs
+++ b/Robust.Client/UserInterface/Control.Input.cs
@@ -53,7 +53,11 @@ namespace Robust.Client.UserInterface
{
}
- protected internal virtual void TextEntered(GUITextEventArgs args)
+ protected internal virtual void TextEntered(GUITextEnteredEventArgs args)
+ {
+ }
+
+ protected internal virtual void TextEditing(GUITextEditingEventArgs args)
{
}
}
@@ -110,21 +114,21 @@ namespace Robust.Client.UserInterface
}
}
- public sealed class GUITextEventArgs : TextEventArgs
+ ///
+ /// Information about text typed on a keyboard-focused UI control.
+ ///
+ /// The control spawning this event.
+ /// Event data for the typed text.
+ public sealed record GUITextEnteredEventArgs(Control SourceControl, TextEnteredEventArgs TextEnteredEvent)
{
///
- /// The control spawning this event.
+ /// The text typed by the user.
///
- public Control SourceControl { get; }
-
- public GUITextEventArgs(Control sourceControl,
- uint codePoint)
- : base(codePoint)
- {
- SourceControl = sourceControl;
- }
+ public string Text => TextEnteredEvent.Text;
}
+ public sealed record GUITextEditingEventArgs(Control SourceControl, TextEditingEventArgs Event);
+
public abstract class GUIMouseEventArgs : InputEventArgs
{
///
diff --git a/Robust.Client/UserInterface/Controls/LineEdit.cs b/Robust.Client/UserInterface/Controls/LineEdit.cs
index 87a86d127..33b9cd88d 100644
--- a/Robust.Client/UserInterface/Controls/LineEdit.cs
+++ b/Robust.Client/UserInterface/Controls/LineEdit.cs
@@ -18,7 +18,8 @@ namespace Robust.Client.UserInterface.Controls
[Virtual]
public class LineEdit : Control
{
- private const float BlinkTime = 0.5f;
+ [Dependency] private readonly IClyde _clyde = default!;
+
private const float MouseScrollDelay = 0.001f;
public const string StylePropertyStyleBox = "stylebox";
@@ -36,8 +37,7 @@ namespace Robust.Client.UserInterface.Controls
private int _drawOffset;
- private float _cursorBlinkTimer;
- private bool _cursorCurrentlyLit;
+ private TextEditShared.CursorBlink _blink;
private readonly LineEditRenderBox _renderBox;
private bool _mouseSelectingText;
@@ -163,6 +163,9 @@ namespace Robust.Client.UserInterface.Controls
public bool IgnoreNext { get; set; }
+ private (int start, int length)? _imeData;
+
+
// TODO:
// I decided to not implement the entire LineEdit API yet,
// since most of it won't be used yet (if at all).
@@ -171,9 +174,12 @@ namespace Robust.Client.UserInterface.Controls
// Second future me reporting, thanks again.
// Third future me is here to say thanks.
// Fourth future me is here to continue the tradition.
+ // Fifth future me is unsure what this is even about but continues to be grateful.
public LineEdit()
{
+ IoCManager.InjectDependencies(this);
+
MouseFilter = MouseFilterMode.Stop;
CanKeyboardFocus = true;
KeyboardFocusOnClick = true;
@@ -235,12 +241,9 @@ namespace Robust.Client.UserInterface.Controls
{
base.FrameUpdate(args);
- _cursorBlinkTimer -= args.DeltaSeconds;
- if (_cursorBlinkTimer <= 0)
- {
- _cursorBlinkTimer += BlinkTime;
- _cursorCurrentlyLit = !_cursorCurrentlyLit;
- }
+ IgnoreNext = false;
+
+ _blink.FrameUpdate(args);
if (_mouseSelectingText)
{
@@ -281,9 +284,9 @@ namespace Robust.Client.UserInterface.Controls
return finalSize;
}
- public event Action? OnTextTyped;
+ public event Action? OnTextTyped;
- protected internal override void TextEntered(GUITextEventArgs args)
+ protected internal override void TextEntered(GUITextEnteredEventArgs args)
{
base.TextEntered(args);
@@ -298,10 +301,63 @@ namespace Robust.Client.UserInterface.Controls
return;
}
- InsertAtCursor(args.AsRune.ToString());
+ InsertAtCursor(args.Text);
OnTextTyped?.Invoke(args);
}
+ protected internal override void TextEditing(GUITextEditingEventArgs args)
+ {
+ base.TextEditing(args);
+
+ if (!Editable)
+ return;
+
+ // TODO: yeah so uh this ignores all the valid checks and everything like that.
+ // Uh....
+
+ var ev = args.Event;
+ var startChars = ev.GetStartChars();
+
+ // Just break an active composition and build it anew to handle in-progress ones.
+ AbortIme();
+
+ if (ev.Text != "")
+ {
+ if (_selectionStart != _cursorPosition)
+ {
+ // Delete active text selection.
+ InsertAtCursor("");
+ }
+
+ var startPos = _cursorPosition;
+ _text = _text[..startPos] + ev.Text + _text[startPos..];
+
+ _selectionStart = _cursorPosition = startPos + startChars;
+ _imeData = (startPos, ev.Text.Length);
+
+ _updatePseudoClass();
+ }
+ }
+
+ private void AbortIme(bool delete = true)
+ {
+ if (!_imeData.HasValue)
+ return;
+
+ if (delete)
+ {
+ var (imeStart, imeLength) = _imeData.Value;
+
+ _text = _text.Remove(imeStart, imeLength);
+
+ _selectionStart = _cursorPosition = imeStart;
+
+ _updatePseudoClass();
+ }
+
+ _imeData = null;
+ }
+
public event Action? OnBackspace;
protected internal override void KeyBindDown(GUIBoundKeyEventArgs args)
@@ -417,13 +473,13 @@ namespace Robust.Client.UserInterface.Controls
}
else if (args.Function == EngineKeyFunctions.TextCursorWordLeft)
{
- _selectionStart = _cursorPosition = PrevWordPosition(_text, _cursorPosition);
+ _selectionStart = _cursorPosition = TextEditShared.PrevWordPosition(_text, _cursorPosition);
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorWordRight)
{
- _selectionStart = _cursorPosition = NextWordPosition(_text, _cursorPosition);
+ _selectionStart = _cursorPosition = TextEditShared.NextWordPosition(_text, _cursorPosition);
args.Handle();
}
@@ -451,13 +507,13 @@ namespace Robust.Client.UserInterface.Controls
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectWordLeft)
{
- _cursorPosition = PrevWordPosition(_text, _cursorPosition);
+ _cursorPosition = TextEditShared.PrevWordPosition(_text, _cursorPosition);
args.Handle();
}
else if (args.Function == EngineKeyFunctions.TextCursorSelectWordRight)
{
- _cursorPosition = NextWordPosition(_text, _cursorPosition);
+ _cursorPosition = TextEditShared.NextWordPosition(_text, _cursorPosition);
args.Handle();
}
@@ -567,7 +623,7 @@ namespace Robust.Client.UserInterface.Controls
}
// Reset this so the cursor is always visible immediately after a keybind is pressed.
- _resetCursorBlink();
+ _blink.Reset();
void ShiftCursorLeft()
{
@@ -697,14 +753,23 @@ namespace Robust.Client.UserInterface.Controls
base.KeyboardFocusEntered();
// Reset this so the cursor is always visible immediately after gaining focus..
- _resetCursorBlink();
+ _blink.Reset();
OnFocusEnter?.Invoke(new LineEditEventArgs(this, _text));
+
+ if (Editable)
+ {
+ _clyde.TextInputStart();
+ }
}
protected internal override void KeyboardFocusExited()
{
base.KeyboardFocusExited();
+
OnFocusExit?.Invoke(new LineEditEventArgs(this, _text));
+
+ _clyde.TextInputStop();
+ AbortIme(delete: false);
}
[Pure]
@@ -752,102 +817,6 @@ namespace Robust.Client.UserInterface.Controls
_getStyleBox().Draw(handle, PixelSizeBox);
}
- // Approach for NextWordPosition and PrevWordPosition taken from Avalonia.
- internal static int NextWordPosition(string str, int cursor)
- {
- if (cursor >= str.Length)
- {
- return str.Length;
- }
-
- var charClass = GetCharClass(Rune.GetRuneAt(str, cursor));
-
- var i = cursor;
-
- IterForward(charClass);
- IterForward(CharClass.Whitespace);
-
- return i;
-
- void IterForward(CharClass cClass)
- {
- while (i < str.Length)
- {
- var rune = Rune.GetRuneAt(str, i);
-
- if (GetCharClass(rune) != cClass)
- break;
-
- i += rune.Utf16SequenceLength;
- }
- }
- }
-
- internal static int PrevWordPosition(string str, int cursor)
- {
- if (cursor == 0)
- {
- return 0;
- }
-
- var startRune = GetRuneBackwards(str, cursor - 1);
- var charClass = GetCharClass(startRune);
-
- var i = cursor;
- IterBackward();
-
- if (charClass == CharClass.Whitespace)
- {
- if (!Rune.TryGetRuneAt(str, i - 1, out var midRune))
- midRune = Rune.GetRuneAt(str, i - 2);
- charClass = GetCharClass(midRune);
-
- IterBackward();
- }
-
- return i;
-
- void IterBackward()
- {
- while (i > 0)
- {
- var rune = GetRuneBackwards(str, i - 1);
-
- if (GetCharClass(rune) != charClass)
- break;
-
- i -= rune.Utf16SequenceLength;
- }
- }
-
- static Rune GetRuneBackwards(string str, int i)
- {
- return Rune.TryGetRuneAt(str, i, out var rune) ? rune : Rune.GetRuneAt(str, i - 1);
- }
- }
-
- internal static CharClass GetCharClass(Rune rune)
- {
- if (Rune.IsWhiteSpace(rune))
- {
- return CharClass.Whitespace;
- }
-
- if (Rune.IsLetterOrDigit(rune))
- {
- return CharClass.AlphaNumeric;
- }
-
- return CharClass.Other;
- }
-
- internal enum CharClass : byte
- {
- Other,
- AlphaNumeric,
- Whitespace
- }
-
public sealed class LineEditEventArgs : EventArgs
{
public LineEdit Control { get; }
@@ -903,7 +872,8 @@ namespace Robust.Client.UserInterface.Controls
var font = _master._getFont();
var renderedTextColor = _master._getFontColor();
- var offsetY = (contentBox.Height - font.GetHeight(UIScale)) / 2;
+ var uiScale = UIScale;
+ var offsetY = (contentBox.Height - font.GetHeight(uiScale)) / 2;
var renderedText = _master.IsPlaceHolderVisible ? _master._placeHolder! : _master._text;
DebugTools.AssertNotNull(renderedText);
@@ -915,9 +885,22 @@ namespace Robust.Client.UserInterface.Controls
var posX = 0;
var actualCursorPosition = 0;
var actualSelectionStartPosition = 0;
+
+ var actualImeStartPosition = 0;
+ var actualImeEndPosition = 0;
+
+ var imeStartIndex = -1;
+ var imeEndIndex = -1;
+
+ if (_master._imeData.HasValue)
+ {
+ (imeStartIndex, var length) = _master._imeData.Value;
+ imeEndIndex = imeStartIndex + length;
+ }
+
foreach (var chr in renderedText.EnumerateRunes())
{
- if (!font.TryGetCharMetrics(chr, UIScale, out var metrics))
+ if (!font.TryGetCharMetrics(chr, uiScale, out var metrics))
{
count += 1;
continue;
@@ -926,6 +909,10 @@ namespace Robust.Client.UserInterface.Controls
posX += metrics.Advance;
count += chr.Utf16SequenceLength;
+ // NOTE: Due to the way this code works, these if statements don't get triggered
+ // if the relevant positions are all the way at the left of the LineEdit.
+ // This happens to be fine, because in that case the horizontal position = 0,
+ // and that's what we initialize the variables to by default.
if (count == _master._cursorPosition)
{
actualCursorPosition = posX;
@@ -935,6 +922,16 @@ namespace Robust.Client.UserInterface.Controls
{
actualSelectionStartPosition = posX;
}
+
+ if (count == imeStartIndex)
+ {
+ actualImeStartPosition = posX;
+ }
+
+ if (count == imeEndIndex)
+ {
+ actualImeEndPosition = posX;
+ }
}
var totalLength = posX;
@@ -961,12 +958,12 @@ namespace Robust.Client.UserInterface.Controls
actualSelectionStartPosition -= drawOffset;
// Actually render.
- var baseLine = (-drawOffset, offsetY + font.GetAscent(UIScale)) +
+ var baseLine = (-drawOffset, offsetY + font.GetAscent(uiScale)) +
contentBox.TopLeft;
foreach (var rune in renderedText.EnumerateRunes())
{
- if (!font.TryGetCharMetrics(rune, UIScale, out var metrics))
+ if (!font.TryGetCharMetrics(rune, uiScale, out var metrics))
{
continue;
}
@@ -980,7 +977,7 @@ namespace Robust.Client.UserInterface.Controls
// Make sure we're not off the left edge of the box.
if (baseLine.X + metrics.BearingX + metrics.Width >= contentBox.Left)
{
- font.DrawChar(handle, rune, baseLine, UIScale, renderedTextColor);
+ font.DrawChar(handle, rune, baseLine, uiScale, renderedTextColor);
}
baseLine += (metrics.Advance, 0);
@@ -1003,25 +1000,43 @@ namespace Robust.Client.UserInterface.Controls
color);
}
- if (_master._cursorCurrentlyLit)
- {
- var color = _master.StylePropertyDefault(
- StylePropertyCursorColor,
- Color.White);
+ var cursorColor = _master.StylePropertyDefault(
+ StylePropertyCursorColor,
+ Color.White);
- handle.DrawRect(
- new UIBox2(actualCursorPosition, contentBox.Top, actualCursorPosition + 1,
- contentBox.Bottom),
- color);
+ cursorColor.A *= _master._blink.Opacity;
+
+ handle.DrawRect(
+ new UIBox2(actualCursorPosition, contentBox.Top, actualCursorPosition + 1,
+ contentBox.Bottom),
+ cursorColor);
+
+ {
+ // Update IME position.
+ var imeBox = new UIBox2(
+ actualCursorPosition,
+ contentBox.Top,
+ contentBox.Right,
+ contentBox.Bottom);
+
+ _master._clyde.TextInputSetRect((UIBox2i) imeBox.Translated(GlobalPixelPosition));
}
}
+
+ // Draw IME underline if necessary.
+ if (_master._imeData.HasValue)
+ {
+ var y = baseLine.Y + font.GetDescent(uiScale);
+ var rect = new UIBox2(
+ actualImeStartPosition,
+ y - 1,
+ actualImeEndPosition,
+ y
+ );
+
+ handle.DrawRect(rect, renderedTextColor);
+ }
}
}
-
- private void _resetCursorBlink()
- {
- _cursorCurrentlyLit = true;
- _cursorBlinkTimer = BlinkTime;
- }
}
}
diff --git a/Robust.Client/UserInterface/Controls/OSWindow.cs b/Robust.Client/UserInterface/Controls/OSWindow.cs
index ce4df0a91..b8f996d79 100644
--- a/Robust.Client/UserInterface/Controls/OSWindow.cs
+++ b/Robust.Client/UserInterface/Controls/OSWindow.cs
@@ -134,6 +134,13 @@ namespace Robust.Client.UserInterface.Controls
_root = UserInterfaceManager.CreateWindowRoot(ClydeWindow);
_root.AddChild(this);
+
+ Shown();
+ }
+
+ protected virtual void Shown()
+ {
+
}
///
diff --git a/Robust.Client/UserInterface/Controls/OutputPanel.cs b/Robust.Client/UserInterface/Controls/OutputPanel.cs
index 2a57a4c0a..6af3f451f 100644
--- a/Robust.Client/UserInterface/Controls/OutputPanel.cs
+++ b/Robust.Client/UserInterface/Controls/OutputPanel.cs
@@ -217,10 +217,9 @@ namespace Robust.Client.UserInterface.Controls
}
[System.Diagnostics.Contracts.Pure]
- private int _getScrollSpeed()
+ private float _getScrollSpeed()
{
- var font = _getFont();
- return font.GetLineHeight(UIScale) * 2;
+ return GetScrollSpeed(_getFont(), UIScale);
}
[System.Diagnostics.Contracts.Pure]
@@ -236,5 +235,10 @@ namespace Robust.Client.UserInterface.Controls
base.UIScaleChanged();
}
+
+ internal static float GetScrollSpeed(Font font, float scale)
+ {
+ return font.GetLineHeight(scale) * 2;
+ }
}
}
diff --git a/Robust.Client/UserInterface/Controls/TextEdit.cs b/Robust.Client/UserInterface/Controls/TextEdit.cs
new file mode 100644
index 000000000..6cb5ab840
--- /dev/null
+++ b/Robust.Client/UserInterface/Controls/TextEdit.cs
@@ -0,0 +1,1556 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Contracts;
+using System.Numerics;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Shared.Collections;
+using Robust.Shared.Console;
+using Robust.Shared.Input;
+using Robust.Shared.IoC;
+using Robust.Shared.Maths;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using Robust.Shared.ViewVariables;
+using Vector2 = Robust.Shared.Maths.Vector2;
+
+namespace Robust.Client.UserInterface.Controls;
+
+///
+/// A multi-line text editing control.
+///
+///
+///
+/// This control uses a to performantly manipulate editable text.
+///
+///
+/// Text selection and cursor positions are tracked with two values: a "selection start" and a "cursor position".
+/// When you do not have a selection (i.e. just a lone cursor) these values are at the same position.
+/// If you have an active selection, the cursor position is the part of the selection you can move around
+/// (via mouse or shift+left/right), while the start is the part that stays in place always.
+///
+///
+/// Positions inside this control are all in chars.
+/// Cursor positions and such should never be inside a surrogate pair, however.
+///
+///
+public sealed class TextEdit : Control
+{
+ [Dependency] private readonly IClipboardManager _clipboard = null!;
+ [Dependency] private readonly IClyde _clyde = null!;
+
+ // @formatter:off
+ public const string StylePropertyCursorColor = "cursor-color";
+ public const string StylePropertySelectionColor = "selection-color";
+ public const string StylePseudoClassNotEditable = "notEditable";
+ public const string StylePseudoClassPlaceholder = "placeholder";
+ // @formatter:on
+
+ private readonly RenderBox _renderBox;
+ private readonly VScrollBar _scrollBar;
+
+ private CursorPos _cursorPosition;
+ private CursorPos _selectionStart;
+
+ // Cached last horizontal cursor position for vertical cursor movement.
+ // Conceptually, moving the cursor up/down across "keeps" the horizontal position as much as possible.
+ // This allows it to remain in position even when the cursor is moving past "valleys" of short lines.
+ private float? _horizontalCursorPos;
+
+ // Stores the positions of all line breaks inside the edited text.
+ // The format is a list of all indices into the text rope where a line break should occur.
+ // These line breaks are "before" the indexed character. So if I have the string "AB", with a line break at index 1,
+ // that means the line break is BETWEEN A and B.
+ //
+ // Line breaks come from either explicit newlines (\n) or from word-wrapping behavior.
+ // It should be noted that in the case of newlines, the newline character is actually considered "on the next line".
+ // This also has implications for cursor bias, see below.
+ private ValueList _lineBreaks;
+
+ private Rope.Node _textRope = Rope.Leaf.Empty;
+ private Rope.Node? _placeholder;
+ private bool _lineUpdateQueued;
+
+ private bool _editable = true;
+
+ // Uncommitted IME positions are stored directly in the text rope.
+ // This field tracks the start position thereof, and how long it is.
+ // The intent is that the text is cut from the rope again if the composition gets cancelled or edited.
+ private (CursorPos start, int length)? _imeData;
+
+ // Yay fancy blink animation!!!!
+ private TextEditShared.CursorBlink _blink;
+
+ // State for click-hold text selection.
+ private bool _mouseSelectingText;
+ private Vector2 _lastMouseSelectPos;
+
+ // Debug overlay stuff.
+ internal bool DebugOverlay;
+ private Vector2? _lastDebugMousePos;
+
+ public TextEdit()
+ {
+ IoCManager.InjectDependencies(this);
+
+ AddChild(_renderBox = new RenderBox(this));
+ AddChild(_scrollBar = new VScrollBar { HorizontalAlignment = HAlignment.Right });
+
+ CanKeyboardFocus = true;
+ KeyboardFocusOnClick = true;
+ MouseFilter = MouseFilterMode.Stop;
+ DefaultCursorShape = CursorShape.IBeam;
+ }
+
+ ///
+ /// The current position of the cursor in the text rope.
+ ///
+ ///
+ /// See the class remarks for how text selection works in this control.
+ ///
+ ///
+ /// Thrown if the given position would put the cursor inside a surrogate pair.
+ ///
+ public CursorPos CursorPosition
+ {
+ get => _cursorPosition;
+ set
+ {
+ var clamped = MathHelper.Clamp(value.Index, 0, TextLength);
+ if (TextLength != 0 && TextLength != clamped && !Rope.TryGetRuneAt(TextRope, clamped, out _))
+ throw new ArgumentException("Cannot set cursor inside surrogate pair.");
+
+ _cursorPosition = value with { Index = clamped };
+ _selectionStart = _cursorPosition;
+ }
+ }
+
+ ///
+ /// The start position of the selection in the text rope.
+ ///
+ ///
+ /// See the class remarks for how text selection works in this control.
+ ///
+ ///
+ /// Thrown if the given position would put the cursor inside a surrogate pair.
+ ///
+ public CursorPos SelectionStart
+ {
+ get => _selectionStart;
+ set
+ {
+ var clamped = MathHelper.Clamp(value.Index, 0, TextLength);
+ if (TextLength != 0 && TextLength != clamped && !Rope.TryGetRuneAt(TextRope, clamped, out _))
+ throw new ArgumentException("Cannot set cursor inside surrogate pair.");
+
+ _selectionStart = value with { Index = clamped };
+ }
+ }
+
+ ///
+ /// The text rope that can be viewed and edited.
+ ///
+ public Rope.Node TextRope
+ {
+ get => _textRope;
+ set
+ {
+ _textRope = value;
+ QueueLineBreakUpdate();
+ UpdatePseudoClass();
+ }
+ }
+
+ ///
+ /// True to allow editing by the user. False to make it read-only.
+ ///
+ public bool Editable
+ {
+ get => _editable;
+ set
+ {
+ _editable = value;
+ DefaultCursorShape = _editable ? CursorShape.IBeam : CursorShape.Arrow;
+ UpdatePseudoClass();
+ }
+ }
+
+ ///
+ /// The lower (in string index terms) end of the active text selection.
+ ///
+ ///
+ /// Confusingly, because text is read top-to-bottom, "lower" is actually higher up on your monitor.
+ ///
+ public CursorPos SelectionLower => CursorPos.Min(_selectionStart, _cursorPosition);
+
+ ///
+ /// The upper (in string index terms) end of the active text selection.
+ ///
+ ///
+ /// Confusingly, because text is read top-to-bottom, "upper" is actually lower down on your monitor.
+ ///
+ public CursorPos SelectionUpper => CursorPos.Max(_selectionStart, _cursorPosition);
+
+ ///
+ /// The amount of chars selected by the active selection.
+ ///
+ public int SelectionLength => Math.Abs(_selectionStart.Index - _cursorPosition.Index);
+
+ // TODO: cache
+ public int TextLength => (int)Rope.CalcTotalLength(TextRope);
+
+ public System.Range SelectionRange => (SelectionLower.Index)..(SelectionUpper.Index);
+
+ ///
+ /// A placeholder text to be displayed when no actual text is entered.
+ ///
+ ///
+ ///
+ /// This isn't editable by the user, but to make the internals simpler, it is still exposed as a rope.
+ ///
+ ///
+ /// When set to null, the placeholder style pseudo-class will not be applied when the actual text content is empty.
+ /// If set to an empty text rope this does happen, but obviously no text is displayed either way.
+ ///
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ public Rope.Node? Placeholder
+ {
+ get => _placeholder;
+ set
+ {
+ _placeholder = value;
+ UpdatePseudoClass();
+ QueueLineBreakUpdate();
+ }
+ }
+
+ private bool IsPlaceholderVisible => Rope.IsNullOrEmpty(_textRope) && _placeholder != null;
+
+ // Table used by cursor movement system, see below.
+ private static readonly Dictionary MoveTypeMap = new()
+ {
+ // @formatter:off
+ { EngineKeyFunctions.TextCursorLeft, MoveType.Left },
+ { EngineKeyFunctions.TextCursorRight, MoveType.Right },
+ { EngineKeyFunctions.TextCursorUp, MoveType.Up },
+ { EngineKeyFunctions.TextCursorDown, MoveType.Down },
+ { EngineKeyFunctions.TextCursorWordLeft, MoveType.LeftWord },
+ { EngineKeyFunctions.TextCursorWordRight, MoveType.RightWord },
+ { EngineKeyFunctions.TextCursorBegin, MoveType.BeginOfLine },
+ { EngineKeyFunctions.TextCursorEnd, MoveType.EndOfLine },
+
+ { EngineKeyFunctions.TextCursorSelectLeft, MoveType.Left | MoveType.SelectFlag },
+ { EngineKeyFunctions.TextCursorSelectRight, MoveType.Right | MoveType.SelectFlag },
+ { EngineKeyFunctions.TextCursorSelectUp, MoveType.Up | MoveType.SelectFlag },
+ { EngineKeyFunctions.TextCursorSelectDown, MoveType.Down | MoveType.SelectFlag },
+ { EngineKeyFunctions.TextCursorSelectWordLeft, MoveType.LeftWord | MoveType.SelectFlag },
+ { EngineKeyFunctions.TextCursorSelectWordRight, MoveType.RightWord | MoveType.SelectFlag },
+ { EngineKeyFunctions.TextCursorSelectBegin, MoveType.BeginOfLine | MoveType.SelectFlag },
+ { EngineKeyFunctions.TextCursorSelectEnd, MoveType.EndOfLine | MoveType.SelectFlag },
+ // @formatter:on
+ };
+
+ protected internal override void KeyBindDown(GUIBoundKeyEventArgs args)
+ {
+ base.KeyBindDown(args);
+
+ if (args.Handled)
+ return;
+
+ var doCursorVisible = true;
+
+ if (MoveTypeMap.TryGetValue(args.Function, out var moveType))
+ {
+ // Most movement operations like normal vs word-bound work the same with or without an active text selection.
+ // To allow sharing this code, we map key functions to a flag that defines the actual operation,
+ // making code reuse easy.
+
+ // Calculate actual new position.
+ var selectFlag = (moveType & MoveType.SelectFlag) != 0;
+ var newPos = CalcCursorMove(moveType & MoveType.ActionMask, selectFlag, out var keepH);
+
+ _cursorPosition = newPos;
+
+ // If not selecting text, keep selection start at cursor (possibly breaking an active selection).
+ if (!selectFlag)
+ _selectionStart = _cursorPosition;
+
+ if (!keepH)
+ InvalidateHorizontalCursorPos();
+
+ args.Handle();
+ }
+ else if (args.Function == EngineKeyFunctions.TextBackspace)
+ {
+ if (Editable)
+ {
+ var changed = false;
+ var oldText = _textRope;
+ var cursor = _cursorPosition;
+ var selectStart = _selectionStart;
+ if (_selectionStart != _cursorPosition)
+ {
+ TextRope = Rope.Delete(oldText, SelectionLower.Index, SelectionLength);
+ _cursorPosition = SelectionLower;
+ changed = true;
+ }
+ else if (_cursorPosition.Index != 0)
+ {
+ var remPos = _cursorPosition.Index - 1;
+ var remAmt = 1;
+ // If this is a low surrogate remove two chars to remove the whole pair.
+ if (char.IsLowSurrogate(Rope.Index(oldText, remPos)))
+ {
+ remPos -= 1;
+ remAmt = 2;
+ }
+
+ TextRope = Rope.Delete(oldText, remPos, remAmt);
+ _cursorPosition.Index -= remAmt;
+ changed = true;
+ }
+
+ if (changed)
+ {
+ _selectionStart = _cursorPosition;
+ // OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
+ // _updatePseudoClass();
+ // OnBackspace?.Invoke(new LineEditBackspaceEventArgs(oldText, _text, cursor, selectStart));
+ }
+
+ InvalidateHorizontalCursorPos();
+
+ args.Handle();
+ }
+ }
+ else if (args.Function == EngineKeyFunctions.TextDelete)
+ {
+ if (Editable)
+ {
+ var changed = false;
+ if (_selectionStart != _cursorPosition)
+ {
+ TextRope = Rope.Delete(TextRope, SelectionLower.Index, SelectionLength);
+ _cursorPosition = SelectionLower;
+ changed = true;
+ }
+ else if (_cursorPosition.Index < TextLength)
+ {
+ var remAmt = 1;
+ if (char.IsHighSurrogate(Rope.Index(TextRope, _cursorPosition.Index)))
+ remAmt = 2;
+
+ TextRope = Rope.Delete(TextRope, _cursorPosition.Index, remAmt);
+ changed = true;
+ }
+
+ if (changed)
+ {
+ _selectionStart = _cursorPosition;
+ // OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
+ // _updatePseudoClass();
+ }
+
+ InvalidateHorizontalCursorPos();
+
+ args.Handle();
+ }
+ }
+ else if (args.Function == EngineKeyFunctions.TextNewline)
+ {
+ InsertAtCursor("\n");
+
+ InvalidateHorizontalCursorPos();
+
+ args.Handle();
+ }
+ else if (args.Function == EngineKeyFunctions.TextSelectAll)
+ {
+ _cursorPosition = new CursorPos(TextLength, LineBreakBias.Bottom);
+ _selectionStart = new CursorPos(0, LineBreakBias.Top);
+
+ InvalidateHorizontalCursorPos();
+
+ args.Handle();
+ }
+ else if (args.Function == EngineKeyFunctions.UIClick || args.Function == EngineKeyFunctions.TextCursorSelect)
+ {
+ _mouseSelectingText = true;
+ _lastMouseSelectPos = args.RelativePosition;
+
+ // Find closest cursor position under mouse.
+ var index = GetIndexAtPos(args.RelativePosition);
+
+ _cursorPosition = index;
+
+ if (args.Function != EngineKeyFunctions.TextCursorSelect)
+ {
+ _selectionStart = _cursorPosition;
+ }
+
+ InvalidateHorizontalCursorPos();
+
+ args.Handle();
+
+ doCursorVisible = false;
+ }
+ else if (args.Function == EngineKeyFunctions.TextCopy)
+ {
+ var range = SelectionRange;
+ var text = Rope.CollapseSubstring(TextRope, range);
+ if (text.Length != 0)
+ {
+ _clipboard.SetText(text);
+ }
+
+ args.Handle();
+ }
+ else if (args.Function == EngineKeyFunctions.TextCut)
+ {
+ if (Editable || SelectionLower != SelectionUpper)
+ {
+ var range = SelectionRange;
+ var text = Rope.CollapseSubstring(TextRope, range);
+ if (text.Length != 0)
+ {
+ _clipboard.SetText(text);
+ }
+
+ InsertAtCursor("");
+ }
+
+ args.Handle();
+ }
+ else if (args.Function == EngineKeyFunctions.TextPaste)
+ {
+ if (Editable)
+ {
+ async void DoPaste()
+ {
+ // Happens asynchronously, be aware
+ var text = await _clipboard.GetText();
+ InsertAtCursor(text);
+ EnsureCursorVisible();
+ }
+
+ DoPaste();
+ }
+
+ args.Handle();
+ doCursorVisible = false;
+ }
+ else if (args.Function == EngineKeyFunctions.TextReleaseFocus)
+ {
+ ReleaseKeyboardFocus();
+ args.Handle();
+ return;
+ }
+
+ if (args.Handled)
+ {
+ // Reset this so the cursor is always visible immediately after a keybind is pressed.
+ _blink.Reset();
+
+ if (doCursorVisible)
+ EnsureCursorVisible();
+ }
+ }
+
+ private void CacheHorizontalCursorPos(CursorPos pos)
+ {
+ EnsureLineBreaksUpdated();
+
+ if (_horizontalCursorPos != null)
+ return;
+
+ _horizontalCursorPos = GetHorizontalPositionAtIndex(pos);
+ }
+
+ ///
+ /// Calculate the position the cursor should move to with a certain move.
+ ///
+ ///
+ /// This method is not pure: while it does not modify the actual cursor position yet,
+ /// only calculating the next position, it still calls CacheHorizontalCursorPos manually.
+ ///
+ /// The type of move the cursor is doing.
+ /// Whether a selection is being expanded.
+ ///
+ /// Whether the cached horizontal cursor position must be kept instead of being invalidates.
+ ///
+ /// The new position of the cursor in the text.
+ private CursorPos CalcCursorMove(MoveType type, bool select, out bool keepHorizontalCursorPos)
+ {
+ DebugTools.Assert(BitOperations.PopCount((uint)type) == 1, "Only a single movement bit may be set in the type");
+
+ keepHorizontalCursorPos = false;
+
+ var breakingSelection = _selectionStart != _cursorPosition && !select;
+ switch (type)
+ {
+ case MoveType.Left:
+ {
+ if (breakingSelection)
+ {
+ // If we're breaking an active selection, move to the lower side of it.
+ return SelectionLower;
+ }
+
+ var (_, lineStart, _) = GetLineForCursorPos(_cursorPosition);
+
+ if (_cursorPosition.Bias == LineBreakBias.Bottom && _cursorPosition.Index == lineStart)
+ {
+ // Swap cursor bias around to make the cursor appear at the end of the previous line.
+ // This maintains the index, it's the same position in the text
+ return _cursorPosition with { Bias = LineBreakBias.Top };
+ }
+
+ var newPos = CursorShiftedLeft();
+ // Explicit newlines work kinda funny with bias, so keep it at top there.
+ var bias = Rope.Index(TextRope, newPos) == '\n'
+ ? LineBreakBias.Top
+ : LineBreakBias.Bottom;
+
+ return new CursorPos(newPos, bias);
+ }
+ case MoveType.Right:
+ {
+ if (breakingSelection)
+ {
+ return SelectionUpper;
+ }
+
+ var (_, _, lineEnd) = GetLineForCursorPos(_cursorPosition);
+ // Explicit newlines work kinda funny with bias, so keep it at top there.
+ if (_cursorPosition.Bias == LineBreakBias.Top
+ && _cursorPosition.Index == lineEnd
+ && _cursorPosition.Index != TextLength
+ && Rope.Index(TextRope, _cursorPosition.Index) != '\n')
+ {
+ // Swap cursor bias around to make the cursor appear at the start of the next line.
+ // This maintains the index, it's the same position in the text.
+ return _cursorPosition with { Bias = LineBreakBias.Bottom };
+ }
+
+ return new CursorPos(CursorShiftedRight(), LineBreakBias.Top);
+ }
+ case MoveType.LeftWord:
+ {
+ var runes = Rope.EnumerateRunesReverse(TextRope, _cursorPosition.Index);
+ var pos = _cursorPosition.Index + TextEditShared.PrevWordPosition(runes.GetEnumerator());
+
+ return new CursorPos(pos, LineBreakBias.Bottom);
+ }
+ case MoveType.RightWord:
+ {
+ var runes = Rope.EnumerateRunes(TextRope, _cursorPosition.Index);
+ var pos = _cursorPosition.Index + TextEditShared.NextWordPosition(runes.GetEnumerator());
+
+ return new CursorPos(pos, LineBreakBias.Bottom);
+ }
+ case MoveType.Up:
+ {
+ var cursor = _cursorPosition;
+ if (breakingSelection)
+ {
+ // If we're in a selection, we move from the selection LOWER upwards, not the cursor position.
+ InvalidateHorizontalCursorPos();
+
+ cursor = SelectionLower;
+ }
+
+ CacheHorizontalCursorPos(cursor);
+ DebugTools.Assert(_horizontalCursorPos.HasValue, "Horizontal cursor pos must be cached!");
+
+ var (line, _, _) = GetLineForCursorPos(cursor);
+
+ if (line == 0)
+ {
+ // We're on the top line already, move to the start of it instead.
+ return new CursorPos(0, LineBreakBias.Top);
+ }
+
+ keepHorizontalCursorPos = true;
+
+ return GetIndexAtHorizontalPos(line - 1, _horizontalCursorPos!.Value);
+ }
+ case MoveType.Down:
+ {
+ var cursor = _cursorPosition;
+ if (breakingSelection)
+ {
+ // If we're in a selection, we move from the selection LOWER upwards, not the cursor position.
+ InvalidateHorizontalCursorPos();
+
+ cursor = SelectionUpper;
+ }
+
+ CacheHorizontalCursorPos(cursor);
+ DebugTools.Assert(_horizontalCursorPos.HasValue, "Horizontal cursor pos must be cached!");
+
+ var (line, _, _) = GetLineForCursorPos(cursor);
+
+ if (line == _lineBreaks.Count)
+ {
+ // On the last line already, move to the end of it.
+ return new CursorPos(TextLength, LineBreakBias.Top);
+ }
+
+ keepHorizontalCursorPos = true;
+
+ return GetIndexAtHorizontalPos(line + 1, _horizontalCursorPos!.Value);
+ }
+ case MoveType.BeginOfLine:
+ {
+ var (_, lineStart, _) = GetLineForCursorPos(_cursorPosition);
+ if (Rope.Index(TextRope, lineStart) == '\n')
+ lineStart += 1;
+
+ return new CursorPos(lineStart, LineBreakBias.Bottom);
+ }
+ case MoveType.EndOfLine:
+ {
+ var (_, _, lineEnd) = GetLineForCursorPos(_cursorPosition);
+ return new CursorPos(lineEnd, LineBreakBias.Top);
+ }
+ default:
+ throw new ArgumentOutOfRangeException(nameof(type), type, null);
+ }
+ }
+
+ [Pure]
+ private int CursorShiftedLeft()
+ {
+ if (_cursorPosition.Index == 0)
+ return _cursorPosition.Index;
+
+ return (int)Rope.RuneShiftLeft(_cursorPosition.Index, _textRope);
+ }
+
+ [Pure]
+ private int CursorShiftedRight()
+ {
+ if (_cursorPosition.Index == TextLength)
+ return _cursorPosition.Index;
+
+ return (int)Rope.RuneShiftRight(_cursorPosition.Index, _textRope);
+ }
+
+ protected internal override void KeyBindUp(GUIBoundKeyEventArgs args)
+ {
+ base.KeyBindUp(args);
+
+ if (args.Function == EngineKeyFunctions.UIClick || args.Function == EngineKeyFunctions.TextCursorSelect)
+ {
+ _mouseSelectingText = false;
+ }
+ }
+
+ private void InvalidateHorizontalCursorPos()
+ {
+ _horizontalCursorPos = null;
+ }
+
+ protected internal override void TextEntered(GUITextEnteredEventArgs args)
+ {
+ base.TextEntered(args);
+
+ if (!Editable)
+ return;
+
+ InsertAtCursor(args.Text);
+ _blink.Reset();
+ EnsureCursorVisible();
+ // OnTextTyped?.Invoke(args);
+ }
+
+ protected internal override void TextEditing(GUITextEditingEventArgs args)
+ {
+ base.TextEditing(args);
+
+ if (!Editable)
+ return;
+
+ var ev = args.Event;
+ var startChars = ev.GetStartChars();
+
+ // Just break an active composition and build it anew to handle in-progress ones.
+ AbortIme();
+
+ if (ev.Text != "")
+ {
+ if (_selectionStart != _cursorPosition)
+ {
+ // Delete active text selection.
+ InsertAtCursor("");
+ }
+
+ var startPos = _cursorPosition;
+ TextRope = Rope.Insert(TextRope, startPos.Index, ev.Text);
+
+ _selectionStart = _cursorPosition = new CursorPos(startPos.Index + startChars, LineBreakBias.Top);
+ _imeData = (startPos, ev.Text.Length);
+ }
+
+ EnsureCursorVisible();
+ }
+
+ private void AbortIme(bool delete = true)
+ {
+ if (!_imeData.HasValue)
+ return;
+
+ if (delete)
+ {
+ var (imeStart, imeLength) = _imeData.Value;
+
+ TextRope = Rope.Delete(TextRope, imeStart.Index, imeLength);
+
+ _selectionStart = _cursorPosition = imeStart;
+ }
+
+ _imeData = null;
+ }
+
+ protected override Vector2 ArrangeOverride(Vector2 finalSize)
+ {
+ var size = base.ArrangeOverride(finalSize);
+
+ _scrollBar.Page = size.Y * UIScale;
+
+ UpdateLineBreaks((int)(size.X * UIScale));
+
+ return size;
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ EnsureLineBreaksUpdated();
+
+ _blink.FrameUpdate(args);
+
+ if (_mouseSelectingText)
+ {
+ var contentBox = PixelSizeBox;
+
+ var index = GetIndexAtPos(_lastMouseSelectPos);
+
+ _cursorPosition = index;
+
+ // Only move scrollbar when the cursor is dragging above/below the text control.
+ if (_lastMouseSelectPos.Y < contentBox.Top)
+ {
+ EnsureCursorVisible();
+ }
+ else if (_lastMouseSelectPos.Y > contentBox.Bottom)
+ {
+ EnsureCursorVisible();
+ }
+ }
+ }
+
+ [Pure]
+ private Font GetFont()
+ {
+ return StylePropertyDefault("font", UserInterfaceManager.ThemeDefaults.DefaultFont);
+ }
+
+ [Pure]
+ private Color GetFontColor()
+ {
+ return StylePropertyDefault("font-color", Color.White);
+ }
+
+ internal void QueueLineBreakUpdate()
+ {
+ _lineUpdateQueued = true;
+ }
+
+ private void EnsureLineBreaksUpdated()
+ {
+ if (_lineUpdateQueued)
+ UpdateLineBreaks(PixelWidth);
+ }
+
+ public void InsertAtCursor(string text)
+ {
+ // Strip newlines.
+ var lower = SelectionLower.Index;
+ var upper = SelectionUpper.Index;
+
+ TextRope = Rope.ReplaceSubstring(TextRope, lower, upper - lower, text);
+
+ _selectionStart = _cursorPosition = new CursorPos(lower + text.Length, LineBreakBias.Top);
+ // OnTextChanged?.Invoke(new LineEditEventArgs(this, _text));
+ // _updatePseudoClass();
+ }
+
+ private void UpdateLineBreaks(int pixelWidth)
+ {
+ _lineBreaks.Clear();
+ InvalidateHorizontalCursorPos();
+
+ var font = GetFont();
+ var scale = UIScale;
+
+ var wordWrap = new WordWrap(pixelWidth);
+ int? breakLine;
+
+ foreach (var rune in Rope.EnumerateRunes(GetDisplayRope()))
+ {
+ wordWrap.NextRune(rune, out breakLine, out var breakNewLine, out var skip);
+ CheckLineBreak(breakLine);
+ CheckLineBreak(breakNewLine);
+ if (skip)
+ continue;
+
+ // Uh just skip unknown characters I guess.
+ if (!font.TryGetCharMetrics(rune, scale, out var metrics))
+ continue;
+
+ wordWrap.NextMetrics(metrics, out breakLine, out var abort);
+ CheckLineBreak(breakLine);
+ if (abort)
+ return;
+ }
+
+ wordWrap.FinalizeText(out breakLine);
+ CheckLineBreak(breakLine);
+
+ void CheckLineBreak(int? line)
+ {
+ if (line is { } l)
+ {
+ _lineBreaks.Add(l);
+ }
+ }
+
+ // Update scroll bar max size.
+ var lineCount = GetLineCount();
+ _scrollBar.MaxValue = Math.Max(_scrollBar.Page, lineCount * font.GetLineHeight(scale));
+
+ _lineUpdateQueued = false;
+ }
+
+ ///
+ /// Get the rope of text actually being displayed. This may be the placeholder.
+ ///
+ private Rope.Node GetDisplayRope()
+ {
+ if (!Rope.IsNullOrEmpty(_textRope))
+ return _textRope;
+
+ return _placeholder ?? Rope.Leaf.Empty;
+ }
+
+ private int GetLineCount()
+ {
+ return _lineBreaks.Count + 1;
+ }
+
+ private CursorPos GetIndexAtPos(Vector2 position)
+ {
+ EnsureLineBreaksUpdated();
+
+ var clickPos = position * UIScale;
+ clickPos.Y += _scrollBar.Value;
+
+ var font = GetFont();
+
+ var lineHeight = font.GetLineHeight(UIScale);
+ var lineIndex = (int)(clickPos.Y / lineHeight);
+
+ return GetIndexAtHorizontalPos(lineIndex, position.X);
+ }
+
+ private CursorPos GetIndexAtHorizontalPos(int line, float horizontalPos)
+ {
+ var contentBox = PixelSizeBox;
+ var font = GetFont();
+ var uiScale = UIScale;
+ horizontalPos *= uiScale;
+
+ (int, int) FindVerticalLine()
+ {
+ // Step one: find the vertical line containing the mouse position.
+
+ if (line > _lineBreaks.Count)
+ {
+ // Below the last line, return the far end of the last line then.
+ return (TextLength, TextLength);
+ }
+
+ if (line < 0)
+ {
+ // Above the first line, clamp.
+ return (0, 0);
+ }
+
+ return (
+ line == 0 ? 0 : _lineBreaks[line - 1],
+ _lineBreaks.Count == line ? TextLength : _lineBreaks[line]
+ );
+ }
+
+ // textIdx = start index on the vertical line we're on.
+ // breakIdx = where the next line starts.
+ var (textIdx, breakIdx) = FindVerticalLine();
+
+ var chrPosX = 0f;
+ var lastChrPosX = 0f;
+ var index = textIdx;
+ foreach (var rune in Rope.EnumerateRunes(TextRope, textIdx))
+ {
+ if (index >= breakIdx)
+ {
+ break;
+ }
+
+ if (!font.TryGetCharMetrics(rune, uiScale, out var metrics))
+ {
+ index += rune.Utf16SequenceLength;
+ continue;
+ }
+
+ if (chrPosX > horizontalPos)
+ {
+ break;
+ }
+
+ lastChrPosX = chrPosX;
+ chrPosX += metrics.Advance;
+ index += rune.Utf16SequenceLength;
+
+ if (chrPosX > contentBox.Right)
+ {
+ break;
+ }
+ }
+
+ // Distance between the right side of the glyph overlapping the mouse and the mouse.
+ var distanceRight = chrPosX - horizontalPos;
+ // Same but left side.
+ var distanceLeft = horizontalPos - lastChrPosX;
+ // If the mouse is closer to the left of the glyph we lower the index one, so we select before that glyph.
+ if (index > 0 && distanceRight > distanceLeft)
+ {
+ index = (int)Rope.RuneShiftLeft(index, TextRope);
+ }
+
+ return new CursorPos(index, index == textIdx ? LineBreakBias.Bottom : LineBreakBias.Top);
+ }
+
+ private float GetHorizontalPositionAtIndex(CursorPos pos)
+ {
+ EnsureLineBreaksUpdated();
+
+ var hPos = 0;
+ var font = GetFont();
+ var uiScale = UIScale;
+
+ var (_, lineStart, _) = GetLineForCursorPos(pos);
+ using var runeEnumerator = Rope.EnumerateRunes(TextRope, lineStart).GetEnumerator();
+
+ var i = lineStart;
+ while (true)
+ {
+ if (i >= pos.Index)
+ break;
+
+ if (!runeEnumerator.MoveNext())
+ break;
+
+ var rune = runeEnumerator.Current;
+ if (font.TryGetCharMetrics(rune, uiScale, out var metrics))
+ hPos += metrics.Advance;
+
+ i += rune.Utf16SequenceLength;
+ }
+
+ return hPos / uiScale;
+ }
+
+ private (int lineIdx, int lineStart, int lineEnd) GetLineForCursorPos(CursorPos pos)
+ {
+ DebugTools.Assert(pos.Index >= 0);
+
+ EnsureLineBreaksUpdated();
+
+ if (_lineBreaks.Count == 0)
+ return (0, 0, TextLength);
+
+ int i;
+ for (i = 0; i < _lineBreaks.Count; i++)
+ {
+ var lineIdx = _lineBreaks[i];
+ if (pos.Bias == LineBreakBias.Bottom ? (lineIdx > pos.Index) : (lineIdx >= pos.Index))
+ {
+ if (i == 0)
+ {
+ // First line
+ return (0, 0, lineIdx);
+ }
+
+ return (i, _lineBreaks[i - 1], lineIdx);
+ }
+ }
+
+ // Position is on last line.
+ return (_lineBreaks.Count, _lineBreaks[^1], TextLength);
+ }
+
+ private int GetStartOfLine(int lineIndex)
+ {
+ if (lineIndex <= 0)
+ {
+ // First line: start of text
+ return 0;
+ }
+
+ if (lineIndex > _lineBreaks.Count)
+ {
+ // Past the last line: just put it at text end so nothing happens I guess.
+ return TextLength;
+ }
+
+ return _lineBreaks[lineIndex - 1];
+ }
+
+ protected internal override void MouseExited()
+ {
+ base.MouseExited();
+
+ _lastDebugMousePos = null;
+ }
+
+ protected internal override void MouseMove(GUIMouseMoveEventArgs args)
+ {
+ base.MouseMove(args);
+
+ _lastDebugMousePos = args.RelativePosition;
+ _lastMouseSelectPos = args.RelativePosition;
+ }
+
+ protected internal override void MouseWheel(GUIMouseWheelEventArgs args)
+ {
+ base.MouseWheel(args);
+
+ if (MathHelper.CloseToPercent(0, args.Delta.Y))
+ return;
+
+ _scrollBar.ValueTarget -= GetScrollSpeed() * args.Delta.Y;
+ }
+
+ [Pure]
+ private float GetScrollSpeed()
+ {
+ return OutputPanel.GetScrollSpeed(GetFont(), UIScale);
+ }
+
+ private void EnsureCursorVisible()
+ {
+ EnsureLineBreaksUpdated();
+
+ var font = GetFont();
+
+ var scrollOffset = _scrollBar.Value;
+ var (cursorLine, _, _) = GetLineForCursorPos(_cursorPosition);
+
+ var cursorMargin = font.GetLineHeight(UIScale) * 1.5f;
+ var (lineT, lineB) = GetBoundsOfLine(cursorLine);
+
+ // Give the cursor some margin so it's not *right* up at the visible edge.
+ lineT -= cursorMargin;
+ lineB += cursorMargin;
+
+ // Vertical boundaries of the vertical section of text.
+ var visibleT = scrollOffset;
+ var visibleB = scrollOffset + PixelSize.Y;
+
+ // Make the scroll bar move to a position where the cursor is visible within margin.
+
+ if (lineT < visibleT)
+ {
+ // Part of the line is ABOVE the visible region, move scrollbar UP.
+
+ _scrollBar.ValueTarget = lineT;
+ }
+ else if (lineB > visibleB)
+ {
+ // Part of the line is BELOW the visible region, move scrollbar DOWN.
+
+ _scrollBar.ValueTarget = lineB - PixelHeight;
+ }
+ }
+
+ private (float start, float end) GetBoundsOfLine(int line)
+ {
+ var font = GetFont();
+ var lineHeight = font.GetLineHeight(UIScale);
+ return (lineHeight * line, lineHeight * (line + 1));
+ }
+
+ private void UpdatePseudoClass()
+ {
+ SetOnlyStylePseudoClass(IsPlaceholderVisible ? StylePseudoClassPlaceholder : null);
+ if (!Editable)
+ AddStylePseudoClass(StylePseudoClassNotEditable);
+ }
+
+
+ ///
+ /// Sub-control responsible for doing the actual rendering work.
+ ///
+ ///
+ /// This is a sub-control to use .
+ ///
+ private sealed class RenderBox : Control
+ {
+ // Arrow shapes/data for the debug overlay.
+ private static readonly (Vector2, Vector2)[] ArrowUp =
+ {
+ ((8, 14), (8, 2)),
+ ((4, 7), (8, 2)),
+ ((12, 7), (8, 2)),
+ };
+
+ private static readonly (Vector2, Vector2)[] ArrowDown =
+ {
+ ((8, 14), (8, 2)),
+ ((4, 9), (8, 14)),
+ ((12, 9), (8, 14)),
+ };
+
+ private static readonly Vector2 ArrowSize = (16, 16);
+
+ private readonly TextEdit _master;
+
+ public RenderBox(TextEdit master)
+ {
+ _master = master;
+
+ RectClipContent = true;
+ }
+
+ protected internal override void Draw(DrawingHandleScreen handle)
+ {
+ CursorPos? drawIndexDebug = null;
+ if (_master.DebugOverlay && _master._lastDebugMousePos is { } mouse)
+ {
+ drawIndexDebug = _master.GetIndexAtPos(mouse);
+ }
+
+ var drawBox = PixelSizeBox;
+ var font = _master.GetFont();
+ var renderedTextColor = _master.GetFontColor();
+
+ if (_master.DebugOverlay && _master._horizontalCursorPos is { } hPos)
+ {
+ handle.DrawLine(
+ (hPos + drawBox.Left, drawBox.Top),
+ (hPos + drawBox.Left, drawBox.Bottom),
+ Color.Purple);
+ }
+
+ var scrollOffset = -_master._scrollBar.Value;
+
+ var scale = UIScale;
+ var baseLine = new Vector2(0, scrollOffset + font.GetAscent(scale));
+ var height = font.GetLineHeight(scale);
+ var descent = font.GetDescent(scale);
+
+ var viewT = -scrollOffset;
+
+ var startLineIndex = (int)(viewT / height);
+ var startIdx = _master.GetStartOfLine(startLineIndex);
+
+ var lineBreakIndex = startLineIndex;
+ var count = startIdx;
+
+ var selectionLower = _master.SelectionLower;
+ var selectionUpper = _master.SelectionUpper;
+
+ baseLine.Y += startLineIndex * height;
+
+ int? selectStartPos = null;
+ int? selectEndPos = null;
+ var selecting = false;
+
+ var imeStartIndex = -1;
+ var imeEndIndex = -1;
+
+ int? imeStartPos = null;
+ int? imeEndPos = null;
+ var imeing = false;
+
+ if (_master._imeData.HasValue)
+ {
+ var (start, length) = _master._imeData.Value;
+ imeStartIndex = start.Index;
+ imeEndIndex = imeStartIndex + length;
+
+ if (imeStartIndex < startIdx && imeEndIndex > startIdx)
+ {
+ imeing = true;
+ imeStartPos = 0;
+ }
+ }
+
+ if (selectionLower.Index < startIdx && selectionUpper.Index > startIdx)
+ {
+ selecting = true;
+ selectStartPos = 0;
+ }
+
+ foreach (var rune in Rope.EnumerateRunes(_master.GetDisplayRope(), startIdx))
+ {
+ CheckDrawCursors(LineBreakBias.Top);
+
+ if (lineBreakIndex < _master._lineBreaks.Count
+ && _master._lineBreaks[lineBreakIndex] == count)
+ {
+ // Line break
+ // Check to handle
+
+ PostDrawLine();
+
+ baseLine = new Vector2(drawBox.Left, baseLine.Y + height);
+ lineBreakIndex += 1;
+
+ selectStartPos = selecting ? 0 : null;
+ selectEndPos = null;
+
+ imeStartPos = imeing ? 0 : null;
+ imeEndPos = null;
+
+ if (baseLine.Y - height > drawBox.Height)
+ {
+ // Past the bottom of the visible area of the screen: no need to render anything else.
+ break;
+ }
+ }
+
+ CheckDrawCursors(LineBreakBias.Bottom);
+
+ baseLine.X += font.DrawChar(handle, rune, baseLine, scale, renderedTextColor);
+
+ count += rune.Utf16SequenceLength;
+ }
+
+ // Also draw cursor if it's at the very end.
+ CheckDrawCursors(LineBreakBias.Bottom);
+ CheckDrawCursors(LineBreakBias.Top);
+ PostDrawLine();
+
+ // Draw cursor bias
+ if (_master.DebugOverlay)
+ {
+ var arrow = _master.CursorPosition.Bias == LineBreakBias.Bottom ? ArrowDown : ArrowUp;
+ foreach (var (to, from) in arrow)
+ {
+ var offset = new Vector2(0, drawBox.Bottom - ArrowSize.Y);
+ handle.DrawLine(to + offset, from + offset, Color.Green);
+ }
+ }
+
+ void CheckDrawCursors(LineBreakBias bias)
+ {
+ var pos = new CursorPos(count, bias);
+
+ if (drawIndexDebug == pos)
+ {
+ handle.DrawRect(
+ new UIBox2(
+ baseLine.X,
+ baseLine.Y - height + descent,
+ baseLine.X + 1,
+ baseLine.Y + descent),
+ Color.Yellow);
+ }
+
+ if (_master.HasKeyboardFocus() && _master._cursorPosition == pos)
+ {
+ var cursorColor = _master.StylePropertyDefault(
+ StylePropertyCursorColor,
+ Color.White);
+
+ cursorColor.A *= _master._blink.Opacity;
+
+ handle.DrawRect(
+ new UIBox2(
+ baseLine.X,
+ baseLine.Y - height + descent,
+ baseLine.X + 1,
+ baseLine.Y + descent),
+ cursorColor);
+
+ if (UserInterfaceManager.KeyboardFocused == _master)
+ {
+ var box = (UIBox2i)new UIBox2(
+ baseLine.X,
+ baseLine.Y - height + descent,
+ drawBox.Right,
+ baseLine.Y + descent);
+
+ _master._clyde.TextInputSetRect(box.Translated(GlobalPixelPosition));
+ }
+ }
+
+ if (selectionLower == pos)
+ {
+ selecting = true;
+ selectStartPos = (int)baseLine.X;
+ }
+
+ if (selectionUpper == pos)
+ {
+ selecting = false;
+ selectEndPos = (int)baseLine.X;
+ }
+
+ if (count == imeStartIndex)
+ {
+ imeing = true;
+ imeStartPos = (int)baseLine.X;
+ }
+
+ if (count == imeEndIndex)
+ {
+ imeing = false;
+ imeEndPos = (int)baseLine.X;
+ }
+ }
+
+ void PostDrawLine()
+ {
+ if (selectStartPos != null)
+ {
+ var rect = new UIBox2(
+ selectStartPos.Value,
+ baseLine.Y - height + descent,
+ selectEndPos ?? baseLine.X,
+ baseLine.Y + descent
+ );
+
+ var color = _master.StylePropertyDefault(
+ StylePropertySelectionColor,
+ Color.CornflowerBlue.WithAlpha(0.25f));
+
+ handle.DrawRect(rect, color);
+ }
+
+ if (_master._imeData.HasValue && imeStartPos.HasValue)
+ {
+ // Draw IME underline.
+ var y = baseLine.Y + font.GetDescent(scale);
+ var rect = new UIBox2(
+ imeStartPos.Value,
+ y - 1,
+ imeEndPos ?? baseLine.X,
+ y
+ );
+
+ handle.DrawRect(rect, renderedTextColor);
+ }
+ }
+ }
+ }
+
+ protected internal override void KeyboardFocusEntered()
+ {
+ base.KeyboardFocusEntered();
+
+ _blink.Reset();
+
+ if (Editable)
+ {
+ _clyde.TextInputStart();
+ }
+ }
+
+ protected internal override void KeyboardFocusExited()
+ {
+ base.KeyboardFocusExited();
+
+ _clyde.TextInputStop();
+ AbortIme(delete: false);
+ }
+
+ ///
+ /// Specifies which line the cursor is positioned at when on a word-wrapping break.
+ ///
+ ///
+ ///
+ /// When words get pushed to a new line due to word-wrapping, a line break is tracked.
+ /// For various reasons, people want to be able to place their cursor on both the end of the "top" line,
+ /// as well as the start of the "bottom" line. These are however the same position in the source text,
+ /// going by raw string indices at least. To allow the code to differentiate between these two positions,
+ /// this bias value is tracked in all cursor positions.
+ ///
+ ///
+ /// This is only for word-wrapping line breaks however. For explicit line breaks created with a newline character,
+ /// the cursor bias should always be "top" so that everything works correctly.
+ ///
+ ///
+ public enum LineBreakBias : byte
+ {
+ // @formatter:off
+ Top = 0,
+ Bottom = 1
+ // @formatter:on
+ }
+
+ ///
+ /// Stores the necessary data for a position in the cursor of the text.
+ ///
+ /// The index of the cursor in the text contents.
+ /// Which direction to bias the cursor to
+ public record struct CursorPos(int Index, LineBreakBias Bias) : IComparable
+ {
+ public static CursorPos Min(CursorPos a, CursorPos b)
+ {
+ var cmp = a.CompareTo(b);
+ if (cmp < 0)
+ return a;
+
+ return b;
+ }
+
+ public static CursorPos Max(CursorPos a, CursorPos b)
+ {
+ var cmp = a.CompareTo(b);
+ if (cmp > 0)
+ return a;
+
+ return b;
+ }
+
+ public int CompareTo(CursorPos other)
+ {
+ var indexComparison = Index.CompareTo(other.Index);
+ if (indexComparison != 0)
+ return indexComparison;
+
+ // If two positions are at the same index, the one with bias top is considered earlier.
+ return ((byte)Bias).CompareTo((byte)other.Bias);
+ }
+
+ public static bool operator <(CursorPos left, CursorPos right)
+ {
+ return left.CompareTo(right) < 0;
+ }
+
+ public static bool operator >(CursorPos left, CursorPos right)
+ {
+ return left.CompareTo(right) > 0;
+ }
+
+ public static bool operator <=(CursorPos left, CursorPos right)
+ {
+ return left.CompareTo(right) <= 0;
+ }
+
+ public static bool operator >=(CursorPos left, CursorPos right)
+ {
+ return left.CompareTo(right) >= 0;
+ }
+ }
+
+ [Flags]
+ private enum MoveType
+ {
+ // @formatter:off
+ Left = 1 << 0,
+ Right = 1 << 1,
+ LeftWord = 1 << 2,
+ RightWord = 1 << 3,
+ Up = 1 << 4,
+ Down = 1 << 5,
+ BeginOfLine = 1 << 6,
+ EndOfLine = 1 << 7,
+
+ ActionMask = (1 << 16) - 1,
+ SelectFlag = 1 << 16,
+ // @formatter:on
+ }
+}
+
+//
+// Debug commands for TextEdit.
+// They work on the active focused control, so you *need* to bind these to a key and press the key.
+//
+
+// bind F12 Command textedit_ropeviz
+internal sealed class TextEditRopeVizCommand : IConsoleCommand
+{
+ [Dependency] private readonly IUserInterfaceManager _ui = default!;
+
+ public string Command => "textedit_ropeviz";
+ public string Description => "";
+ public string Help => "";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (_ui.KeyboardFocused is TextEdit te)
+ {
+ new TextEditRopeViz(te).Show();
+ }
+ }
+}
+
+// bind F11 Command textedit_rebalance
+internal sealed class TextEditRebalanceCommand : IConsoleCommand
+{
+ [Dependency] private readonly IUserInterfaceManager _ui = default!;
+
+ public string Command => "textedit_rebalance";
+ public string Description => "";
+ public string Help => "";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (_ui.KeyboardFocused is TextEdit te)
+ {
+ te.TextRope = Rope.Rebalance(te.TextRope);
+ }
+ }
+}
+
+// bind F10 Command textedit_debugoverlay
+internal sealed class TextEditDebugOverlayCommand : IConsoleCommand
+{
+ [Dependency] private readonly IUserInterfaceManager _ui = default!;
+
+ public string Command => "textedit_debugoverlay";
+ public string Description => "";
+ public string Help => "";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (_ui.KeyboardFocused is TextEdit te)
+ {
+ te.DebugOverlay ^= true;
+ }
+ }
+}
+
+// bind F9 Command textedit_queuelinebreak
+internal sealed class TextEditQueueLineBreakCommand : IConsoleCommand
+{
+ [Dependency] private readonly IUserInterfaceManager _ui = default!;
+
+ public string Command => "textedit_queuelinebreak";
+ public string Description => "";
+ public string Help => "";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (_ui.KeyboardFocused is TextEdit te)
+ {
+ te.QueueLineBreakUpdate();
+ }
+ }
+}
diff --git a/Robust.Client/UserInterface/Controls/TextEditShared.cs b/Robust.Client/UserInterface/Controls/TextEditShared.cs
new file mode 100644
index 000000000..5c0b67458
--- /dev/null
+++ b/Robust.Client/UserInterface/Controls/TextEditShared.cs
@@ -0,0 +1,167 @@
+using System.Collections.Generic;
+using System.Text;
+using Robust.Shared.Maths;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Robust.Client.UserInterface.Controls;
+
+///
+/// Shared logic between and
+///
+internal static class TextEditShared
+{
+ // Approach for NextWordPosition and PrevWordPosition taken from Avalonia.
+
+ //
+ // Functions for calculating next positions when doing word-bound cursor movement (ctrl+left/right).
+ //
+
+ internal static int NextWordPosition(string str, int cursor)
+ {
+ return cursor + NextWordPosition(new StringEnumerateHelpers.SubstringRuneEnumerator(str, cursor));
+ }
+
+ internal static int NextWordPosition(T runes) where T : IEnumerator
+ {
+ if (!runes.MoveNext())
+ return 0;
+
+ var charClass = GetCharClass(runes.Current);
+
+ var i = 0;
+
+ IterForward(charClass);
+ IterForward(CharClass.Whitespace);
+
+ return i;
+
+ void IterForward(CharClass cClass)
+ {
+ do
+ {
+ var rune = runes.Current;
+
+ if (GetCharClass(rune) != cClass)
+ break;
+
+ i += rune.Utf16SequenceLength;
+ } while (runes.MoveNext());
+ }
+ }
+
+ internal static int PrevWordPosition(string str, int cursor)
+ {
+ return cursor + PrevWordPosition(new StringEnumerateHelpers.SubstringReverseRuneEnumerator(str, cursor));
+ }
+
+ internal static int PrevWordPosition(T runes) where T : IEnumerator
+ {
+ if (!runes.MoveNext())
+ return 0;
+
+ var startRune = runes.Current;
+ var charClass = GetCharClass(startRune);
+
+ var i = 0;
+ var keepGoing = IterBackward();
+
+ if (keepGoing && charClass == CharClass.Whitespace)
+ {
+ charClass = GetCharClass(runes.Current);
+
+ IterBackward();
+ }
+
+ return i;
+
+ bool IterBackward()
+ {
+ do
+ {
+ var rune = runes.Current;
+
+ if (GetCharClass(rune) != charClass)
+ return true;
+
+ i -= rune.Utf16SequenceLength;
+ } while (runes.MoveNext());
+
+ return false;
+ }
+ }
+
+ private static CharClass GetCharClass(Rune rune)
+ {
+ if (Rune.IsWhiteSpace(rune))
+ {
+ return CharClass.Whitespace;
+ }
+
+ if (Rune.IsLetterOrDigit(rune))
+ {
+ return CharClass.AlphaNumeric;
+ }
+
+ return CharClass.Other;
+ }
+
+ private enum CharClass : byte
+ {
+ Other,
+ AlphaNumeric,
+ Whitespace
+ }
+
+ ///
+ /// Helper type for the cursor blink animation.
+ ///
+ internal struct CursorBlink
+ {
+ ///
+ /// The total length of the animation.
+ ///
+ private const float BlinkTime = 1.3f;
+
+ // Because of the animation curves used, there is a plateau on either end of the animation.
+ // 0 or t/2 in the animation, and you are exactly in the middle of this plateau.
+ // Now, when we reset the blink (i.e. when the user presses a button),
+ // we want this plateau to stay for a bit longer. So we offset it by this start time in that case.
+ private const float BlinkStartTime = BlinkTime * -0.2f;
+ private const float HalfBlinkTime = BlinkTime / 2;
+
+ public float Opacity;
+ public float Timer;
+
+ public void Reset()
+ {
+ Timer = BlinkTime + BlinkStartTime;
+ UpdateOpacity();
+ }
+
+ public void FrameUpdate(FrameEventArgs args)
+ {
+ Timer += args.DeltaSeconds;
+ UpdateOpacity();
+ }
+
+ private void UpdateOpacity()
+ {
+ if (Timer >= BlinkTime)
+ Timer %= BlinkTime;
+
+ // Manually implement the animation function with easings. The math isn't thaaaaaaaat bad right?
+
+ if (Timer < HalfBlinkTime)
+ {
+ // First half: cursor is dimming.
+ Opacity = 1 - Easings.InOutQuint(Timer * (1 / HalfBlinkTime));
+ }
+ else
+ {
+ // Second half: cursor is brightening again.
+ Opacity = Easings.InOutQuint((Timer - HalfBlinkTime) * (1 / HalfBlinkTime));
+ }
+ }
+ }
+}
diff --git a/Robust.Client/UserInterface/Controls/UIRoot.cs b/Robust.Client/UserInterface/Controls/UIRoot.cs
index c954a8040..6277b7065 100644
--- a/Robust.Client/UserInterface/Controls/UIRoot.cs
+++ b/Robust.Client/UserInterface/Controls/UIRoot.cs
@@ -22,6 +22,8 @@ namespace Robust.Client.UserInterface.Controls
internal Color ActualBgColor => BackgroundColor ?? _styleBgColor;
+ internal Control? StoredKeyboardFocus;
+
protected override void StylePropertiesChanged()
{
base.StylePropertiesChanged();
diff --git a/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs b/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs
index f2e955ffc..137e41c10 100644
--- a/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs
+++ b/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs
@@ -51,7 +51,7 @@ public sealed partial class DebugConsole
AbortActiveCompletions();
}
- private void CommandBarOnOnTextTyped(GUITextEventArgs obj)
+ private void CommandBarOnOnTextTyped(GUITextEnteredEventArgs obj)
{
TypeUpdateCompletions(true);
}
diff --git a/Robust.Client/UserInterface/CustomControls/TextEditRopeViz.cs b/Robust.Client/UserInterface/CustomControls/TextEditRopeViz.cs
new file mode 100644
index 000000000..0209cfd61
--- /dev/null
+++ b/Robust.Client/UserInterface/CustomControls/TextEditRopeViz.cs
@@ -0,0 +1,167 @@
+using System;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Input;
+using Robust.Shared.Maths;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Robust.Client.UserInterface.CustomControls;
+
+internal sealed class TextEditRopeViz : OSWindow
+{
+ private static readonly Color[] LeafColors = CalcLeafColors();
+
+ private readonly TextEdit _textEdit;
+
+ private Vector2 _panOffset;
+
+ private bool _dragging;
+ private Vector2 _dragStartOffset;
+ private Vector2 _dragStartMouse;
+
+ public TextEditRopeViz(TextEdit textEdit)
+ {
+ _textEdit = textEdit;
+
+ MouseFilter = MouseFilterMode.Stop;
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ if (!_textEdit.IsInsideTree)
+ UserInterfaceManager.DeferAction(Close);
+ }
+
+ protected internal override void KeyBindDown(GUIBoundKeyEventArgs args)
+ {
+ base.KeyBindDown(args);
+
+ if (args.Function == EngineKeyFunctions.UIClick)
+ {
+ _dragStartOffset = _panOffset;
+ _dragStartMouse = args.RelativePosition;
+ _dragging = true;
+
+ args.Handle();
+ }
+ }
+
+ protected internal override void KeyBindUp(GUIBoundKeyEventArgs args)
+ {
+ base.KeyBindUp(args);
+
+ if (args.Function == EngineKeyFunctions.UIClick)
+ {
+ _dragging = false;
+
+ args.Handle();
+ }
+ }
+
+ protected internal override void MouseMove(GUIMouseMoveEventArgs args)
+ {
+ base.MouseMove(args);
+
+ if (!_dragging)
+ return;
+
+ _panOffset = args.RelativePosition - _dragStartMouse + _dragStartOffset;
+ }
+
+ protected override void Shown()
+ {
+ base.Shown();
+
+ Root!.Name = nameof(TextEditRopeViz);
+ }
+
+ protected internal override void Draw(DrawingHandleScreen handle)
+ {
+ var root = _textEdit.TextRope;
+
+ var totalDepth = root.Depth;
+
+ DrawNode(root, _panOffset, 0, out _);
+
+ const int nodeWidthHalf = 2;
+
+ float DrawNode(Rope.Node node, Vector2 offset, int depth, out Vector2 nodePos)
+ {
+ switch (node)
+ {
+ case Rope.Branch branch:
+ {
+ var depthOffset = 20 + (totalDepth - depth) * 4;
+ var leftWidth = DrawNode(branch.Left, offset + (0, depthOffset), depth + 1, out var leftPos);
+ var rightWidth = 0f;
+ Vector2? rightPos = null;
+ if (branch.Right is { } right)
+ {
+ rightWidth = DrawNode(right, offset + (leftWidth, depthOffset), depth + 1, out var rightPosOut);
+ rightPos = rightPosOut;
+ }
+
+ nodePos = offset + (leftWidth, 0);
+ handle.DrawLine(nodePos, leftPos, Color.DarkGray);
+
+ if (rightPos is { } rp)
+ {
+ handle.DrawLine(nodePos, rp, Color.DarkGray);
+ }
+ else
+ {
+ handle.DrawLine(nodePos, nodePos + (10, 10), Color.Red);
+ }
+
+ handle.DrawRect(new UIBox2(nodePos - (1, 1), nodePos + (2, 2)), Color.White);
+
+ return leftWidth + rightWidth;
+ }
+ case Rope.Leaf leaf:
+ {
+ var colorIdx = leaf.Text.Length;
+ var color = colorIdx < LeafColors.Length ? LeafColors[colorIdx] : LeafColors[^1];
+ handle.DrawRect(new UIBox2(offset - (2, 2), offset + (3, 3)), color);
+ nodePos = offset;
+ return 6;
+ }
+ default:
+ throw new ArgumentOutOfRangeException(nameof(node));
+ }
+ }
+
+ static UIBox2 Around(Vector2 vec, float size)
+ {
+ return new UIBox2(vec - (size, size), vec + (size, size));
+ }
+ }
+
+ private static Color[] CalcLeafColors()
+ {
+ var colors = new Color[21];
+ colors[0] = Color.Purple;
+
+ InterpColors(colors.AsSpan(1, 10), Color.Red, Color.Lime);
+ InterpColors(colors.AsSpan(10, 10), Color.Red, Color.Lime);
+
+ static void InterpColors(Span colors, Color α, Color β)
+ {
+ α = Color.FromSrgb(α);
+ β = Color.FromSrgb(β);
+
+ for (var i = 0; i < colors.Length; i++)
+ {
+ var λ = (float)i / (colors.Length - 1);
+
+ var color = Color.InterpolateBetween(α, β, λ);
+
+ colors[i] = Color.ToSrgb(color);
+ }
+ }
+
+ return colors;
+ }
+}
diff --git a/Robust.Client/UserInterface/IUserInterfaceManager.cs b/Robust.Client/UserInterface/IUserInterfaceManager.cs
index 36b76cf41..672cb32bc 100644
--- a/Robust.Client/UserInterface/IUserInterfaceManager.cs
+++ b/Robust.Client/UserInterface/IUserInterfaceManager.cs
@@ -115,6 +115,8 @@ namespace Robust.Client.UserInterface
IEnumerable AllRoots { get; }
event Action OnPostDrawUIRoot;
+
+ void DeferAction(Action action);
}
public readonly struct PostDrawUIRootEventArgs
diff --git a/Robust.Client/UserInterface/IUserInterfaceManagerInternal.cs b/Robust.Client/UserInterface/IUserInterfaceManagerInternal.cs
index e2c69097e..5313d8ff9 100644
--- a/Robust.Client/UserInterface/IUserInterfaceManagerInternal.cs
+++ b/Robust.Client/UserInterface/IUserInterfaceManagerInternal.cs
@@ -29,7 +29,9 @@ namespace Robust.Client.UserInterface
void MouseWheel(MouseWheelEventArgs args);
- void TextEntered(TextEventArgs textEvent);
+ void TextEntered(TextEnteredEventArgs textEnteredEvent);
+
+ void TextEditing(TextEditingEventArgs textEvent);
void ControlHidden(Control control);
diff --git a/Robust.Client/UserInterface/RichTextEntry.cs b/Robust.Client/UserInterface/RichTextEntry.cs
index 7872940f0..212ee9ca2 100644
--- a/Robust.Client/UserInterface/RichTextEntry.cs
+++ b/Robust.Client/UserInterface/RichTextEntry.cs
@@ -4,7 +4,6 @@ using System.Text;
using JetBrains.Annotations;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
-using Robust.Shared.Log;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
@@ -54,22 +53,13 @@ namespace Robust.Client.UserInterface
// This method is gonna suck due to complexity.
// Bear with me here.
// I am so deeply sorry for the person adding stuff to this in the future.
+
Height = font.GetHeight(uiScale);
LineBreaks.Clear();
- var maxUsedWidth = 0f;
- // Index we put into the LineBreaks list when a line break should occur.
- var breakIndexCounter = 0;
- // If the CURRENT processing word ends up too long, this is the index to put a line break.
- (int index, float lineSize)? wordStartBreakIndex = null;
- // Word size in pixels.
- var wordSizePixels = 0;
- // The horizontal position of the text cursor.
- var posX = 0;
- var lastRune = new Rune('A');
- // If a word is larger than maxSizeX, we split it.
- // We need to keep track of some data to split it into two words.
- (int breakIndex, int wordSizePixels)? forceSplitData = null;
+ int? breakLine;
+ var wordWrap = new WordWrap(maxSizeX);
+
// Go over every text tag.
// We treat multiple text tags as one continuous one.
// So changing color inside a single word doesn't create a word break boundary.
@@ -86,126 +76,34 @@ namespace Robust.Client.UserInterface
// And go over every character.
foreach (var rune in text.EnumerateRunes())
{
- breakIndexCounter += 1;
-
- if (IsWordBoundary(lastRune, rune) || rune == new Rune('\n'))
- {
- // Word boundary means we know where the word ends.
- if (posX > maxSizeX && lastRune != new Rune(' '))
- {
- DebugTools.Assert(wordStartBreakIndex.HasValue,
- "wordStartBreakIndex can only be null if the word begins at a new line, in which case this branch shouldn't be reached as the word would be split due to being longer than a single line.");
- // We ran into a word boundary and the word is too big to fit the previous line.
- // So we insert the line break BEFORE the last word.
- LineBreaks.Add(wordStartBreakIndex!.Value.index);
- Height += font.GetLineHeight(uiScale);
- maxUsedWidth = Math.Max(maxUsedWidth, wordStartBreakIndex.Value.lineSize);
- posX = wordSizePixels;
- }
-
- // Start a new word since we hit a word boundary.
- //wordSize = 0;
- wordSizePixels = 0;
- wordStartBreakIndex = (breakIndexCounter, posX);
- forceSplitData = null;
-
- // Just manually handle newlines.
- if (rune == new Rune('\n'))
- {
- LineBreaks.Add(breakIndexCounter);
- Height += font.GetLineHeight(uiScale);
- maxUsedWidth = Math.Max(maxUsedWidth, posX);
- posX = 0;
- lastRune = rune;
- wordStartBreakIndex = null;
- continue;
- }
- }
+ wordWrap.NextRune(rune, out breakLine, out var breakNewLine, out var skip);
+ CheckLineBreak(ref this, breakLine);
+ CheckLineBreak(ref this, breakNewLine);
+ if (skip)
+ continue;
// Uh just skip unknown characters I guess.
if (!font.TryGetCharMetrics(rune, uiScale, out var metrics))
- {
- lastRune = rune;
continue;
- }
- // Increase word size and such with the current character.
- var oldWordSizePixels = wordSizePixels;
- wordSizePixels += metrics.Advance;
- // TODO: Theoretically, does it make sense to break after the glyph's width instead of its advance?
- // It might result in some more tight packing but I doubt it'd be noticeable.
- // Also definitely even more complex to implement.
- posX += metrics.Advance;
-
- if (posX > maxSizeX)
- {
- if (!forceSplitData.HasValue)
- {
- forceSplitData = (breakIndexCounter, oldWordSizePixels);
- }
-
- // Oh hey we get to break a word that doesn't fit on a single line.
- if (wordSizePixels > maxSizeX)
- {
- var (breakIndex, splitWordSize) = forceSplitData.Value;
- if (splitWordSize == 0)
- {
- // Happens if there's literally not enough space for a single character so uh...
- // Yeah just don't.
- return;
- }
-
- // Reset forceSplitData so that we can split again if necessary.
- forceSplitData = null;
- LineBreaks.Add(breakIndex);
- Height += font.GetLineHeight(uiScale);
- wordSizePixels -= splitWordSize;
- wordStartBreakIndex = null;
- maxUsedWidth = Math.Max(maxUsedWidth, maxSizeX);
- posX = wordSizePixels;
- }
- }
-
- lastRune = rune;
+ wordWrap.NextMetrics(metrics, out breakLine, out var abort);
+ CheckLineBreak(ref this, breakLine);
+ if (abort)
+ return;
}
}
- // This needs to happen because word wrapping doesn't get checked for the last word.
- if (posX > maxSizeX)
+ Width = wordWrap.FinalizeText(out breakLine);
+ CheckLineBreak(ref this, breakLine);
+
+ void CheckLineBreak(ref RichTextEntry src, int? line)
{
- if (!wordStartBreakIndex.HasValue)
+ if (line is { } l)
{
- Logger.Error(
- "Assert fail inside RichTextEntry.Update, " +
- "wordStartBreakIndex is null on method end w/ word wrap required. " +
- "Dumping relevant stuff. Send this to PJB.");
- Logger.Error($"Message: {Message}");
- Logger.Error($"maxSizeX: {maxSizeX}");
- Logger.Error($"maxUsedWidth: {maxUsedWidth}");
- Logger.Error($"breakIndexCounter: {breakIndexCounter}");
- Logger.Error("wordStartBreakIndex: null (duh)");
- Logger.Error($"wordSizePixels: {wordSizePixels}");
- Logger.Error($"posX: {posX}");
- Logger.Error($"lastChar: {lastRune}");
- Logger.Error($"forceSplitData: {forceSplitData}");
- Logger.Error($"LineBreaks: {string.Join(", ", LineBreaks)}");
-
- throw new Exception(
- "wordStartBreakIndex can only be null if the word begins at a new line," +
- "in which case this branch shouldn't be reached as" +
- "the word would be split due to being longer than a single line.");
+ src.LineBreaks.Add(l);
+ src.Height += font.GetLineHeight(uiScale);
}
-
- LineBreaks.Add(wordStartBreakIndex!.Value.index);
- Height += font.GetLineHeight(uiScale);
- maxUsedWidth = Math.Max(maxUsedWidth, wordStartBreakIndex.Value.lineSize);
}
- else
- {
- maxUsedWidth = Math.Max(maxUsedWidth, posX);
- }
-
- Width = (int) maxUsedWidth;
}
public void Draw(
diff --git a/Robust.Client/UserInterface/UserInterfaceManager.Input.cs b/Robust.Client/UserInterface/UserInterfaceManager.Input.cs
index f00980c69..11c0169b8 100644
--- a/Robust.Client/UserInterface/UserInterfaceManager.Input.cs
+++ b/Robust.Client/UserInterface/UserInterfaceManager.Input.cs
@@ -41,6 +41,8 @@ internal partial class UserInterfaceManager
private Control? _suppliedTooltip;
private const float TooltipDelay = 1;
+ private WindowRoot? _focusedRoot;
+
private static (Control control, Vector2 rel)? MouseFindControlAtPos(Control control, Vector2 position)
{
for (var i = control.ChildCount - 1; i >= 0; i--)
@@ -215,17 +217,28 @@ internal partial class UserInterfaceManager
_doMouseGuiInput(control, guiArgs, (c, ev) => c.MouseWheel(ev), true);
}
- public void TextEntered(TextEventArgs textEvent)
+ public void TextEntered(TextEnteredEventArgs textEnteredEvent)
{
if (KeyboardFocused == null)
{
return;
}
- var guiArgs = new GUITextEventArgs(KeyboardFocused, textEvent.CodePoint);
+ var guiArgs = new GUITextEnteredEventArgs(KeyboardFocused, textEnteredEvent);
KeyboardFocused.TextEntered(guiArgs);
}
+ public void TextEditing(TextEditingEventArgs textEvent)
+ {
+ if (KeyboardFocused == null)
+ {
+ return;
+ }
+
+ var guiArgs = new GUITextEditingEventArgs(KeyboardFocused, textEvent);
+ KeyboardFocused.TextEditing(guiArgs);
+ }
+
public ScreenCoordinates MousePositionScaled => ScreenToUIPosition(_inputManager.MouseScreenPosition);
private static void _doMouseGuiInput(Control? control, T guiEvent, Action action,
diff --git a/Robust.Client/UserInterface/UserInterfaceManager.Layout.cs b/Robust.Client/UserInterface/UserInterfaceManager.Layout.cs
index 660c8c1d5..a1abc04c8 100644
--- a/Robust.Client/UserInterface/UserInterfaceManager.Layout.cs
+++ b/Robust.Client/UserInterface/UserInterfaceManager.Layout.cs
@@ -1,20 +1,14 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
-using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Profiling;
namespace Robust.Client.UserInterface;
internal sealed partial class UserInterfaceManager
{
- private readonly List _roots = new();
- private readonly Dictionary _windowsToRoot = new();
- public IEnumerable AllRoots => _roots;
-
- private readonly List _modalStack = new();
+ private readonly List _modalStack = new();
private void RunMeasure(Control control)
{
@@ -75,6 +69,9 @@ internal sealed partial class UserInterfaceManager
public void ControlRemovedFromTree(Control control)
{
+ if (control.Root?.StoredKeyboardFocus == control)
+ control.Root.StoredKeyboardFocus = null;
+
ReleaseKeyboardFocus(control);
RemoveModal(control);
if (control == CurrentlyHovered)
@@ -154,46 +151,4 @@ internal sealed partial class UserInterfaceManager
{
_arrangeUpdateQueue.Enqueue(control);
}
-
- public WindowRoot CreateWindowRoot(IClydeWindow window)
- {
- if (_windowsToRoot.ContainsKey(window.Id))
- {
- throw new ArgumentException("Window already has a UI root.");
- }
-
- var newRoot = new WindowRoot(window)
- {
- MouseFilter = Control.MouseFilterMode.Ignore,
- HorizontalAlignment = Control.HAlignment.Stretch,
- VerticalAlignment = Control.VAlignment.Stretch,
- UIScaleSet = window.ContentScale.X
- };
-
- _roots.Add(newRoot);
- _windowsToRoot.Add(window.Id, newRoot);
-
- newRoot.StyleSheetUpdate();
- newRoot.InvalidateMeasure();
- QueueMeasureUpdate(newRoot);
-
- return newRoot;
- }
-
- public void DestroyWindowRoot(IClydeWindow window)
- {
- // Destroy window root if this window had one.
- if (!_windowsToRoot.TryGetValue(window.Id, out var root))
- return;
-
- _windowsToRoot.Remove(window.Id);
- _roots.Remove(root);
-
- root.RemoveAllChildren();
- }
-
- public WindowRoot? GetWindowRoot(IClydeWindow window)
- {
- return !_windowsToRoot.TryGetValue(window.Id, out var root) ? null : root;
- }
}
diff --git a/Robust.Client/UserInterface/UserInterfaceManager.Roots.cs b/Robust.Client/UserInterface/UserInterfaceManager.Roots.cs
new file mode 100644
index 000000000..044713d67
--- /dev/null
+++ b/Robust.Client/UserInterface/UserInterfaceManager.Roots.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Collections.Generic;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Map;
+using Robust.Shared.Utility;
+
+namespace Robust.Client.UserInterface;
+
+//
+// Contains primary UI root management logic.
+//
+
+internal sealed partial class UserInterfaceManager
+{
+ private readonly List _roots = new();
+ private readonly Dictionary _windowsToRoot = new();
+ public IEnumerable AllRoots => _roots;
+
+ public WindowRoot CreateWindowRoot(IClydeWindow window)
+ {
+ if (_windowsToRoot.ContainsKey(window.Id))
+ {
+ throw new ArgumentException("Window already has a UI root.");
+ }
+
+ var newRoot = new WindowRoot(window)
+ {
+ MouseFilter = Control.MouseFilterMode.Ignore,
+ HorizontalAlignment = Control.HAlignment.Stretch,
+ VerticalAlignment = Control.VAlignment.Stretch,
+ UIScaleSet = window.ContentScale.X
+ };
+
+ _roots.Add(newRoot);
+ _windowsToRoot.Add(window.Id, newRoot);
+
+ newRoot.StyleSheetUpdate();
+ newRoot.InvalidateMeasure();
+ QueueMeasureUpdate(newRoot);
+
+ if (window.IsFocused)
+ FocusRoot(newRoot);
+
+ return newRoot;
+ }
+
+ public void DestroyWindowRoot(IClydeWindow window)
+ {
+ // Destroy window root if this window had one.
+ if (!_windowsToRoot.TryGetValue(window.Id, out var root))
+ return;
+
+ if (root == _focusedRoot)
+ UnfocusRoot(root);
+
+ _windowsToRoot.Remove(window.Id);
+ _roots.Remove(root);
+
+ root.RemoveAllChildren();
+ }
+
+ public WindowRoot? GetWindowRoot(IClydeWindow window)
+ {
+ return !_windowsToRoot.TryGetValue(window.Id, out var root) ? null : root;
+ }
+
+ private void ClydeOnWindowFocused(WindowFocusedEventArgs eventArgs)
+ {
+ if (GetWindowRoot(eventArgs.Window) is not { } root)
+ return;
+
+ if (eventArgs.Focused)
+ {
+ // Focusing new window.
+ FocusRoot(root);
+ }
+ else
+ {
+ // Unfocusing, should be the active window.
+ if (root != _focusedRoot)
+ {
+ /*_sawmillUI.Warning(
+ "Unfocused window, but its root wasn't focused already! Window: {WindowId}",
+ eventArgs.Window.Id);*/
+ return;
+ }
+
+ UnfocusRoot(root);
+ }
+ }
+
+ private void FocusRoot(WindowRoot root)
+ {
+ DebugTools.Assert(_roots.Contains(root), "Tried to focus invalid UI root.");
+
+ if (_focusedRoot != null)
+ {
+ _sawmillUI.Warning("Already had a focused UI root! Replacing...");
+
+ UnfocusRoot(_focusedRoot);
+
+ DebugTools.Assert(_focusedRoot == null);
+ }
+
+ _focusedRoot = root;
+
+ // Try to restore keyboard-focused UI control from new root.
+ ref var stored = ref root.StoredKeyboardFocus;
+ if (stored != null)
+ {
+ DebugTools.Assert(
+ stored.IsInsideTree,
+ "Stored focused control on root was not inside UI tree anymore!");
+
+ DebugTools.Assert(
+ stored.Root == root,
+ "Stored focused control on root wasn't inside root's own tree!");
+
+ GrabKeyboardFocus(stored);
+ stored = null;
+ }
+ }
+
+ private void UnfocusRoot(WindowRoot root)
+ {
+ var controlFocused = KeyboardFocused;
+ if (controlFocused != null)
+ {
+ if (controlFocused.Root != root)
+ {
+ _sawmillUI.Warning("Keyboard focused control isn't inside focused UI root!");
+ }
+ else
+ {
+ // Save focused control on window root so we can restore it when re-focusing.
+ root.StoredKeyboardFocus = controlFocused;
+ ReleaseKeyboardFocus(controlFocused);
+ }
+ }
+
+ _focusedRoot = null;
+ }
+}
diff --git a/Robust.Client/UserInterface/UserInterfaceManager.cs b/Robust.Client/UserInterface/UserInterfaceManager.cs
index 69ec8dc81..c6fb86d54 100644
--- a/Robust.Client/UserInterface/UserInterfaceManager.cs
+++ b/Robust.Client/UserInterface/UserInterfaceManager.cs
@@ -14,6 +14,7 @@ using Robust.Shared.GameObjects;
using Robust.Shared.Input;
using Robust.Shared.Input.Binding;
using Robust.Shared.IoC;
+using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Network;
@@ -27,6 +28,7 @@ namespace Robust.Client.UserInterface
{
internal sealed partial class UserInterfaceManager : IUserInterfaceManagerInternal
{
+ [Dependency] private readonly IDependencyCollection _rootDependencies = default!;
[Dependency] private readonly IInputManager _inputManager = default!;
[Dependency] private readonly IFontManager _fontManager = default!;
[Dependency] private readonly IClydeInternal _clyde = default!;
@@ -45,6 +47,7 @@ namespace Robust.Client.UserInterface
[Dependency] private readonly ProfManager _prof = default!;
[Dependency] private readonly IReflectionManager _reflectionManager = default!;
[Dependency] private readonly IEntitySystemManager _systemManager = default!;
+ [Dependency] private readonly ILogManager _logManager = default!;
[ViewVariables] public InterfaceTheme ThemeDefaults { get; private set; } = default!;
[ViewVariables]
@@ -77,14 +80,17 @@ namespace Robust.Client.UserInterface
private bool _rendering = true;
+ private readonly Queue _deferQueue = new();
private readonly Queue _styleUpdateQueue = new();
private readonly Queue _measureUpdateQueue = new();
private readonly Queue _arrangeUpdateQueue = new();
private Stylesheet? _stylesheet;
+ private ISawmill _sawmillUI = default!;
+
public void Initialize()
{
- _dependencies = new DependencyCollection(IoCManager.Instance!);
+ _dependencies = new DependencyCollection(_rootDependencies);
_configurationManager.OnValueChanged(CVars.DisplayUIScale, _uiScaleChanged, true);
ThemeDefaults = new InterfaceThemeDummy();
_initScaling();
@@ -114,6 +120,7 @@ namespace Robust.Client.UserInterface
_inputManager.UIKeyBindStateChanged += OnUIKeyBindStateChanged;
_initThemes();
}
+
public void PostInitialize()
{
_initializeScreens();
@@ -121,9 +128,13 @@ namespace Robust.Client.UserInterface
}
private void _initializeCommon()
{
+ _sawmillUI = _logManager.GetSawmill("ui");
+
RootControl = CreateWindowRoot(_clyde.MainWindow);
RootControl.Name = "MainWindowRoot";
+
_clyde.DestroyWindow += WindowDestroyed;
+ _clyde.OnWindowFocused += ClydeOnWindowFocused;
MainViewport = new MainViewportContainer(_eyeManager)
{
@@ -173,6 +184,11 @@ namespace Robust.Client.UserInterface
public event Action? OnPostDrawUIRoot;
+ public void DeferAction(Action action)
+ {
+ _deferQueue.Enqueue(action);
+ }
+
private void WindowDestroyed(WindowDestroyedEventArgs args)
{
DestroyWindowRoot(args.Window);
@@ -266,6 +282,14 @@ namespace Robust.Client.UserInterface
_needUpdateActiveCursor = false;
UpdateActiveCursor();
}
+
+ using (_prof.Group("Deferred actions"))
+ {
+ while (_deferQueue.TryDequeue(out var action))
+ {
+ action();
+ }
+ }
}
private void _render(IRenderHandle renderHandle, ref int total, Control control, Vector2i position, Color modulate,
diff --git a/Robust.Client/UserInterface/WordWrap.cs b/Robust.Client/UserInterface/WordWrap.cs
new file mode 100644
index 000000000..7ee06fbf2
--- /dev/null
+++ b/Robust.Client/UserInterface/WordWrap.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Diagnostics.Contracts;
+using System.Text;
+using Robust.Client.Graphics;
+using Robust.Shared.Log;
+using Robust.Shared.Utility;
+
+namespace Robust.Client.UserInterface;
+
+///
+/// Helper utility struct for word-wrapping calculations.
+///
+internal struct WordWrap
+{
+ private readonly float _maxSizeX;
+
+ public float MaxUsedWidth;
+ // Index we put into the LineBreaks list when a line break should occur.
+ public int BreakIndexCounter;
+ public int NextBreakIndexCounter;
+ // If the CURRENT processing word ends up too long, this is the index to put a line break.
+ public (int index, float lineSize)? WordStartBreakIndex;
+ // Word size in pixels.
+ public int WordSizePixels;
+ // The horizontal position of the text cursor.
+ public int PosX;
+ public Rune LastRune;
+ // If a word is larger than maxSizeX, we split it.
+ // We need to keep track of some data to split it into two words.
+ public (int breakIndex, int wordSizePixels)? ForceSplitData = null;
+
+ public WordWrap(float maxSizeX)
+ {
+ this = default;
+ _maxSizeX = maxSizeX;
+ LastRune = new Rune('A');
+ }
+
+ public void NextRune(Rune rune, out int? breakLine, out int? breakNewLine, out bool skip)
+ {
+ BreakIndexCounter = NextBreakIndexCounter;
+ NextBreakIndexCounter += rune.Utf16SequenceLength;
+
+ breakLine = null;
+ breakNewLine = null;
+ skip = false;
+
+ if (IsWordBoundary(LastRune, rune) || rune == new Rune('\n'))
+ {
+ // Word boundary means we know where the word ends.
+ if (PosX > _maxSizeX && LastRune != new Rune(' '))
+ {
+ DebugTools.Assert(WordStartBreakIndex.HasValue,
+ "wordStartBreakIndex can only be null if the word begins at a new line, in which case this branch shouldn't be reached as the word would be split due to being longer than a single line.");
+ // We ran into a word boundary and the word is too big to fit the previous line.
+ // So we insert the line break BEFORE the last word.
+ breakLine = WordStartBreakIndex!.Value.index;
+ MaxUsedWidth = Math.Max(MaxUsedWidth, WordStartBreakIndex.Value.lineSize);
+ PosX = WordSizePixels;
+ }
+
+ // Start a new word since we hit a word boundary.
+ //wordSize = 0;
+ WordSizePixels = 0;
+ WordStartBreakIndex = (BreakIndexCounter, PosX);
+ ForceSplitData = null;
+
+ // Just manually handle newlines.
+ if (rune == new Rune('\n'))
+ {
+ MaxUsedWidth = Math.Max(MaxUsedWidth, PosX);
+ PosX = 0;
+ WordStartBreakIndex = null;
+ skip = true;
+ breakNewLine = BreakIndexCounter;
+ }
+ }
+
+ LastRune = rune;
+ }
+
+ public void NextMetrics(in CharMetrics metrics, out int? breakLine, out bool abort)
+ {
+ abort = false;
+ breakLine = null;
+
+ // Increase word size and such with the current character.
+ var oldWordSizePixels = WordSizePixels;
+ WordSizePixels += metrics.Advance;
+ // TODO: Theoretically, does it make sense to break after the glyph's width instead of its advance?
+ // It might result in some more tight packing but I doubt it'd be noticeable.
+ // Also definitely even more complex to implement.
+ PosX += metrics.Advance;
+
+ if (PosX <= _maxSizeX)
+ return;
+
+ if (!ForceSplitData.HasValue)
+ {
+ ForceSplitData = (BreakIndexCounter, oldWordSizePixels);
+ }
+
+ // Oh hey we get to break a word that doesn't fit on a single line.
+ if (WordSizePixels > _maxSizeX)
+ {
+ var (breakIndex, splitWordSize) = ForceSplitData.Value;
+ if (splitWordSize == 0)
+ {
+ // Happens if there's literally not enough space for a single character so uh...
+ // Yeah just don't.
+ abort = true;
+ return;
+ }
+
+ // Reset forceSplitData so that we can split again if necessary.
+ ForceSplitData = null;
+ breakLine = breakIndex;
+ WordSizePixels -= splitWordSize;
+ WordStartBreakIndex = null;
+ MaxUsedWidth = Math.Max(MaxUsedWidth, _maxSizeX);
+ PosX = WordSizePixels;
+ }
+ }
+
+ public int FinalizeText(out int? breakLine)
+ {
+ // This needs to happen because word wrapping doesn't get checked for the last word.
+ if (PosX > _maxSizeX)
+ {
+ if (!WordStartBreakIndex.HasValue)
+ {
+ Logger.Error(
+ "Assert fail inside RichTextEntry.Update, " +
+ "wordStartBreakIndex is null on method end w/ word wrap required. " +
+ "Dumping relevant stuff. Send this to PJB.");
+ // Logger.Error($"Message: {Message}");
+ Logger.Error($"maxSizeX: {_maxSizeX}");
+ Logger.Error($"maxUsedWidth: {MaxUsedWidth}");
+ Logger.Error($"breakIndexCounter: {BreakIndexCounter}");
+ Logger.Error("wordStartBreakIndex: null (duh)");
+ Logger.Error($"wordSizePixels: {WordSizePixels}");
+ Logger.Error($"posX: {PosX}");
+ Logger.Error($"lastChar: {LastRune}");
+ Logger.Error($"forceSplitData: {ForceSplitData}");
+ // Logger.Error($"LineBreaks: {string.Join(", ", LineBreaks)}");
+
+ throw new Exception(
+ "wordStartBreakIndex can only be null if the word begins at a new line," +
+ "in which case this branch shouldn't be reached as" +
+ "the word would be split due to being longer than a single line.");
+ }
+
+ breakLine = WordStartBreakIndex.Value.index;
+ MaxUsedWidth = Math.Max(MaxUsedWidth, WordStartBreakIndex.Value.lineSize);
+ }
+ else
+ {
+ breakLine = null;
+ MaxUsedWidth = Math.Max(MaxUsedWidth, PosX);
+ }
+
+ return (int)MaxUsedWidth;
+ }
+
+ [Pure]
+ private static bool IsWordBoundary(Rune a, Rune b)
+ {
+ return a == new Rune(' ') || b == new Rune(' ') || a == new Rune('-') || b == new Rune('-');
+ }
+
+}
diff --git a/Robust.Shared.Maths/Easings.cs b/Robust.Shared.Maths/Easings.cs
new file mode 100644
index 000000000..70afff8b8
--- /dev/null
+++ b/Robust.Shared.Maths/Easings.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace Robust.Shared.Maths;
+
+// Reference: https://easings.net/
+
+internal static class Easings
+{
+ public static float InOutQuint(float p)
+ {
+ return p < 0.5f ? (16 * p * p * p * p * p) : 1 - MathF.Pow(-2 * p + 2, 5) / 2;
+ }
+}
diff --git a/Robust.Shared.Maths/Properties/AssemblyInfo.cs b/Robust.Shared.Maths/Properties/AssemblyInfo.cs
index 10c644d5c..1609dbc4e 100644
--- a/Robust.Shared.Maths/Properties/AssemblyInfo.cs
+++ b/Robust.Shared.Maths/Properties/AssemblyInfo.cs
@@ -4,5 +4,6 @@
[module: SkipLocalsInit]
#endif
+[assembly: InternalsVisibleTo("Robust.Client")]
[assembly: InternalsVisibleTo("Robust.UnitTesting")]
[assembly: InternalsVisibleTo("Content.Benchmarks")]
diff --git a/Robust.Shared/Input/BoundKeyEventArgs.cs b/Robust.Shared/Input/BoundKeyEventArgs.cs
index 6f427dc58..555cc453c 100644
--- a/Robust.Shared/Input/BoundKeyEventArgs.cs
+++ b/Robust.Shared/Input/BoundKeyEventArgs.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics;
using Robust.Shared.Map;
namespace Robust.Shared.Input
@@ -7,6 +8,7 @@ namespace Robust.Shared.Input
/// Event data values for a bound key state change.
///
[Virtual]
+ [DebuggerDisplay("{Function}, {State}, CF: {CanFocus}, H: {Handled}")]
public class BoundKeyEventArgs : EventArgs
{
///
diff --git a/Robust.Shared/Input/BoundKeyMap.cs b/Robust.Shared/Input/BoundKeyMap.cs
index d25332640..08561f8c2 100644
--- a/Robust.Shared/Input/BoundKeyMap.cs
+++ b/Robust.Shared/Input/BoundKeyMap.cs
@@ -70,6 +70,8 @@ namespace Robust.Shared.Input
this.reflectionManager = reflectionManager;
}
+ internal IEnumerable AllKeyFunctions => KeyFunctionsList;
+
public void PopulateKeyFunctionsMap()
{
if (KeyFunctionsMap.Count != 0)
diff --git a/Robust.Shared/Input/KeyFunctions.cs b/Robust.Shared/Input/KeyFunctions.cs
index fa2919edc..9fa751d12 100644
--- a/Robust.Shared/Input/KeyFunctions.cs
+++ b/Robust.Shared/Input/KeyFunctions.cs
@@ -46,6 +46,8 @@ namespace Robust.Shared.Input
// Cursor keys in LineEdit and such.
public static readonly BoundKeyFunction TextCursorLeft = "TextCursorLeft";
public static readonly BoundKeyFunction TextCursorRight = "TextCursorRight";
+ public static readonly BoundKeyFunction TextCursorUp = "TextCursorUp";
+ public static readonly BoundKeyFunction TextCursorDown = "TextCursorDown";
public static readonly BoundKeyFunction TextCursorWordLeft = "TextCursorWordLeft";
public static readonly BoundKeyFunction TextCursorWordRight = "TextCursorWordRight";
public static readonly BoundKeyFunction TextCursorBegin = "TextCursorBegin";
@@ -55,12 +57,15 @@ namespace Robust.Shared.Input
public static readonly BoundKeyFunction TextCursorSelect = "TextCursorSelect";
public static readonly BoundKeyFunction TextCursorSelectLeft = "TextCursorSelectLeft";
public static readonly BoundKeyFunction TextCursorSelectRight = "TextCursorSelectRight";
+ public static readonly BoundKeyFunction TextCursorSelectUp = "TextCursorSelectUp";
+ public static readonly BoundKeyFunction TextCursorSelectDown = "TextCursorSelectDown";
public static readonly BoundKeyFunction TextCursorSelectWordLeft = "TextCursorSelectWordLeft";
public static readonly BoundKeyFunction TextCursorSelectWordRight = "TextCursorSelectWordRight";
public static readonly BoundKeyFunction TextCursorSelectBegin = "TextCursorSelectBegin";
public static readonly BoundKeyFunction TextCursorSelectEnd = "TextCursorSelectEnd";
public static readonly BoundKeyFunction TextBackspace = "TextBackspace";
+ public static readonly BoundKeyFunction TextNewline = "TextNewline";
public static readonly BoundKeyFunction TextSubmit = "TextSubmit";
public static readonly BoundKeyFunction TextSelectAll = "TextSelectAll";
public static readonly BoundKeyFunction TextCopy = "TextCopy";
diff --git a/Robust.Shared/Utility/FormattedMessage.cs b/Robust.Shared/Utility/FormattedMessage.cs
index 4d88ce9df..85f10e56f 100644
--- a/Robust.Shared/Utility/FormattedMessage.cs
+++ b/Robust.Shared/Utility/FormattedMessage.cs
@@ -100,6 +100,11 @@ namespace Robust.Shared.Utility
_tags.Clear();
}
+ public FormattedMessageRuneEnumerator EnumerateRunes()
+ {
+ return new FormattedMessageRuneEnumerator(this);
+ }
+
/// The string without markup tags.
public override string ToString()
{
@@ -192,4 +197,60 @@ namespace Robust.Shared.Utility
public Tag this[int index] => _tags[index];
}
}
+
+ public struct FormattedMessageRuneEnumerator : IEnumerable, IEnumerator
+ {
+ private readonly FormattedMessage _msg;
+ private List.Enumerator _tagEnumerator;
+ private StringRuneEnumerator _runeEnumerator;
+
+ internal FormattedMessageRuneEnumerator(FormattedMessage msg)
+ {
+ _msg = msg;
+ _tagEnumerator = msg.Tags.GetEnumerator();
+ // Rune enumerator will immediately give false on first iteration so I dont' need to special case anything.
+ _runeEnumerator = "".EnumerateRunes();
+ }
+
+ public IEnumerator GetEnumerator() => this;
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public bool MoveNext()
+ {
+ while (!_runeEnumerator.MoveNext())
+ {
+ FormattedMessage.TagText text;
+ while (true)
+ {
+ var result = _tagEnumerator.MoveNext();
+ if (!result)
+ return false;
+
+ if (_tagEnumerator.Current is not FormattedMessage.TagText { Text.Length: > 0 } nextText)
+ continue;
+
+ text = nextText;
+ break;
+ }
+
+ _runeEnumerator = text.Text.EnumerateRunes();
+ }
+
+ return true;
+ }
+
+ public void Reset()
+ {
+ _tagEnumerator = _msg.Tags.GetEnumerator();
+ _runeEnumerator = "".EnumerateRunes();
+ }
+
+ public Rune Current => _runeEnumerator.Current;
+
+ object IEnumerator.Current => Current;
+
+ void IDisposable.Dispose()
+ {
+ }
+ }
}
diff --git a/Robust.Shared/Utility/StringEnumerateHelpers.cs b/Robust.Shared/Utility/StringEnumerateHelpers.cs
new file mode 100644
index 000000000..4f26b9d7a
--- /dev/null
+++ b/Robust.Shared/Utility/StringEnumerateHelpers.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Robust.Shared.Utility;
+
+internal static class StringEnumerateHelpers
+{
+ internal struct SubstringRuneEnumerator : IEnumerable, IEnumerator
+ {
+ private readonly string _source;
+ private int _nextChar;
+ private Rune _current;
+
+ public SubstringRuneEnumerator(string source, int firstChar)
+ {
+ _source = source;
+ _nextChar = firstChar;
+ _current = default;
+ }
+
+ public bool MoveNext()
+ {
+ if (_nextChar >= _source.Length)
+ return false;
+
+ if (!Rune.TryGetRuneAt(_source, _nextChar, out _current))
+ _current = Rune.ReplacementChar;
+
+ _nextChar += _current.Utf16SequenceLength;
+ return true;
+ }
+
+ public void Reset()
+ {
+ throw new NotSupportedException();
+ }
+
+ public readonly Rune Current => _current;
+
+ object IEnumerator.Current => Current;
+
+ public void Dispose()
+ {
+ // Nada.
+ }
+
+ public SubstringRuneEnumerator GetEnumerator() => this;
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ internal struct SubstringReverseRuneEnumerator : IEnumerator, IEnumerable
+ {
+ private string _source;
+ // Contains the next char to return.
+ // If the next char is actually a (valid) surrogate pair, this is INSIDE the pair,
+ // and MoveNext() has to skip more.
+ private int _nextChar;
+ private Rune _current;
+
+ public SubstringReverseRuneEnumerator(string source, int startChar)
+ {
+ _source = source;
+ _nextChar = startChar - 1;
+ _current = default;
+ }
+
+ public bool MoveNext()
+ {
+ if (_nextChar < 0)
+ return false;
+
+ var chr = _source[_nextChar];
+ if (!char.IsSurrogate(chr))
+ {
+ _current = new Rune(chr);
+ }
+ else if (char.IsLowSurrogate(chr) && _nextChar >= 1)
+ {
+ var prevChr = _source[_nextChar - 1];
+ if (char.IsHighSurrogate(prevChr))
+ _current = new Rune(prevChr, chr);
+ else
+ _current = Rune.ReplacementChar;
+ }
+ else
+ {
+ _current = Rune.ReplacementChar;
+ }
+
+ _nextChar -= _current.Utf16SequenceLength;
+ return true;
+ }
+
+ public void Reset()
+ {
+ throw new NotSupportedException();
+ }
+
+ public Rune Current => _current;
+
+ object IEnumerator.Current => Current;
+
+ public void Dispose()
+ {
+ // Nada.
+ }
+
+ public SubstringReverseRuneEnumerator GetEnumerator() => this;
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+}
diff --git a/Robust.Shared/Utility/TextRope.cs b/Robust.Shared/Utility/TextRope.cs
new file mode 100644
index 000000000..fbff63a16
--- /dev/null
+++ b/Robust.Shared/Utility/TextRope.cs
@@ -0,0 +1,561 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
+using System.Linq;
+using System.Text;
+
+namespace Robust.Shared.Utility;
+
+///
+/// A binary tree data structure for efficient storage of large mutable text.
+///
+///
+///
+/// Read the Wikipedia article, nerd
+/// Also read the original paper, it's useful too.
+///
+///
+/// Like strings, ropes are immutable and all "mutating" operations return new copies.
+///
+///
+/// All indexing functions use indices.
+/// While individual rope leaves cannot be larger than an , ropes with many leaves may exceed that.
+///
+///
+public static class Rope
+{
+ internal static readonly int[] FibonacciSequence =
+ {
+ 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946,
+ 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040, 1346269, 2178309,
+ 3524578, 5702887, 9227465, 14930352, 24157817, 39088169, 63245986, 102334155, 165580141,
+ 267914296, 433494437, 701408733, 1134903170, 1836311903
+ };
+
+ ///
+ /// Calculate the total text length of the rope given.
+ ///
+ ///
+ /// For a balanced tree, this is O(log n).
+ ///
+ [Pure]
+ public static long CalcTotalLength(Node? node)
+ {
+ return node switch
+ {
+ Branch branch => branch.Weight + CalcTotalLength(branch.Right),
+ Leaf leaf => leaf.Weight,
+ _ => 0
+ };
+ }
+
+ // TODO: Move to struct enumerator with managed stack memory.
+ ///
+ /// Enumerate all leaves in the rope from left to right.
+ ///
+ public static IEnumerable CollectLeaves(Node node)
+ {
+ var stack = new Stack();
+
+ var leaf = RunTillLeaf(stack, node);
+ yield return leaf;
+
+ while (stack.TryPop(out var branch))
+ {
+ if (branch.Right == null)
+ continue;
+
+ leaf = RunTillLeaf(stack, branch.Right);
+ yield return leaf;
+ }
+
+ static Leaf RunTillLeaf(Stack stack, Node node)
+ {
+ while (node is Branch branch)
+ {
+ stack.Push(branch);
+
+ node = branch.Left;
+ }
+
+ return (Leaf)node;
+ }
+ }
+
+ ///
+ /// Enumerate all leaves in the rope from right to left.
+ ///
+ public static IEnumerable CollectLeavesReverse(Node node)
+ {
+ var stack = new Stack();
+
+ var leaf = RunTillLeaf(stack, node);
+ if (leaf != null)
+ yield return leaf;
+
+ while (stack.TryPop(out var branch))
+ {
+ leaf = RunTillLeaf(stack, branch.Left);
+ if (leaf != null)
+ yield return leaf;
+ }
+
+ static Leaf? RunTillLeaf(Stack stack, Node? node)
+ {
+ while (node is Branch branch)
+ {
+ stack.Push(branch);
+
+ node = branch.Right;
+ }
+
+ return (Leaf?)node;
+ }
+ }
+
+ // TODO: Move to struct enumerator with managed stack memory.
+ ///
+ /// Enumerate all text runes in the rope from left to right.
+ ///
+ public static IEnumerable EnumerateRunes(Node node)
+ {
+ foreach (var leaf in CollectLeaves(node))
+ {
+ foreach (var rune in leaf.Text.EnumerateRunes())
+ {
+ yield return rune;
+ }
+ }
+ }
+
+ ///
+ /// Enumerate all text runes in the rope from left to right, starting at the specified position.
+ ///
+ public static IEnumerable EnumerateRunes(Node node, long startPos)
+ {
+ var pos = 0L;
+
+ // Phase 1: skip over whole leaves that are before the start position.
+ // TODO: Ideally we would navigate the binary tree properly instead of starting from the far left.
+
+ // ReSharper disable once GenericEnumeratorNotDisposed
+ var leaves = CollectLeaves(node).GetEnumerator();
+ while (leaves.MoveNext())
+ {
+ var leaf = leaves.Current;
+ if (pos + leaf.Weight >= startPos)
+ {
+ goto startLeafFound;
+ }
+
+ pos += leaf.Weight;
+ }
+
+ // Didn't find a starting leaf, must mean that startPos >= text length. Oh well?
+ yield break;
+
+ startLeafFound:
+
+ // Phase 2: start halfway through the current leaf.
+ {
+ foreach (var rune in leaves.Current.Text.EnumerateRunes())
+ {
+ if (pos >= startPos)
+ {
+ yield return rune;
+ }
+
+ pos += rune.Utf16SequenceLength;
+ }
+ }
+
+ // Phase 3: just return everything from here on out.
+ while (leaves.MoveNext())
+ {
+ var leaf = leaves.Current;
+ foreach (var rune in leaf.Text.EnumerateRunes())
+ {
+ yield return rune;
+ }
+ }
+ }
+
+ ///
+ /// Enumerate all the runes in the rope, from right to left.
+ ///
+ public static IEnumerable EnumerateRunesReverse(Node node)
+ {
+ foreach (var leaf in CollectLeavesReverse(node))
+ {
+ var enumerator = new StringEnumerateHelpers.SubstringReverseRuneEnumerator(leaf.Text, leaf.Text.Length);
+ while (enumerator.MoveNext())
+ {
+ yield return enumerator.Current;
+ }
+ }
+ }
+
+ ///
+ /// Enumerate all text runes in the rope from right to left, starting at the specified position.
+ ///
+ public static IEnumerable EnumerateRunesReverse(Node node, long endPos)
+ {
+ var pos = CalcTotalLength(node);
+
+ // TODO: Actually start at the position instead of skipping like a worse linked list thanks.
+
+ foreach (var rune in EnumerateRunesReverse(node))
+ {
+ if (pos <= endPos)
+ {
+ yield return rune;
+ }
+
+ pos -= rune.Utf16SequenceLength;
+ }
+ }
+
+ ///
+ /// Check whether the given rope is sufficiently balanced to avoid bad performance.
+ ///
+ [Pure]
+ public static bool IsBalanced(Node node)
+ {
+ var depth = node.Depth;
+ if (depth > FibonacciSequence.Length - 2)
+ return false;
+
+ return FibonacciSequence[depth + 2] <= node.Weight;
+ }
+
+ ///
+ /// Ensure the rope is balanced to ensure decent performance on various operations.
+ ///
+ ///
+ /// If the rope is already balanced, this method does nothing.
+ ///
+ [Pure]
+ public static Node Rebalance(Node node)
+ {
+ if (IsBalanced(node))
+ return node;
+
+ var leaves = CollectLeaves(node).ToArray();
+ return Merge(leaves);
+
+ static Node Merge(ReadOnlySpan leaves)
+ {
+ if (leaves.Length == 1)
+ return leaves[0];
+
+ if (leaves.Length == 2)
+ return new Branch(leaves[0], leaves[1]);
+
+ var mid = leaves.Length / 2;
+ return new Branch(Merge(leaves[..mid]), Merge(leaves[mid..]));
+ }
+ }
+
+ ///
+ /// Get a at the specified index in the rope.
+ ///
+ ///
+ /// For a balanced tree, this is O(log n).
+ ///
+ [Pure]
+ public static char Index(Node rope, long index)
+ {
+ switch (rope)
+ {
+ case Branch branch:
+ if (branch.Weight > index)
+ return Index(branch.Left, index);
+
+ if (branch.Right == null)
+ throw new IndexOutOfRangeException();
+
+ return Index(branch.Right, index - branch.Weight);
+
+ case Leaf leaf:
+ return leaf.Text[(int)index];
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(rope));
+ }
+ }
+
+ ///
+ /// Create a new rope with text spliced in at an index.
+ ///
+ /// The rope to splice text into.
+ /// The position the inserted text should start at.
+ /// The text to insert.
+ /// The new rope containing the spliced data.
+ [Pure]
+ public static Node Insert(Node rope, long index, string value)
+ {
+ var (left, right) = Split(rope, index);
+ return Concat(left, Concat(new Leaf(value), right));
+ }
+
+ ///
+ /// Create a new rope concatenating two given ropes.
+ ///
+ [Pure]
+ public static Node Concat(Node left, Node right)
+ {
+ return new Branch(left, right);
+ }
+
+ ///
+ /// Create a new rope concatenating a rope and a string.
+ ///
+ [Pure]
+ public static Node Concat(Node left, string right)
+ {
+ return Concat(left, new Leaf(right));
+ }
+
+ ///
+ /// Create a new rope concatenating a string with a rope.
+ ///
+ [Pure]
+ public static Node Concat(string left, Node right)
+ {
+ return Concat(new Leaf(left), right);
+ }
+
+ ///
+ /// Return two new ropes split from the given rope at a specified index.
+ ///
+ [Pure]
+ public static (Node left, Node right) Split(Node rope, long index)
+ {
+ switch (rope)
+ {
+ case Branch branch:
+ {
+ if (branch.Weight > index)
+ {
+ var (left, right) = Split(branch.Left, index);
+ return (
+ Rebalance(left),
+ Rebalance(new Branch(right, branch.Right))
+ );
+ }
+
+ if (branch.Weight < index)
+ {
+ var (left, right) = Split(branch.Right ?? Leaf.Empty, index - branch.Weight);
+ return (
+ Rebalance(new Branch(branch.Left, left)),
+ Rebalance(right)
+ );
+ }
+
+ return (branch.Left, branch.Right ?? Leaf.Empty);
+ }
+ case Leaf leaf:
+ {
+ var left = new Leaf(leaf.Text[..(int)index]);
+ var right = new Leaf(leaf.Text[(int)index..]);
+ return (left, right);
+ }
+ default:
+ throw new ArgumentOutOfRangeException(nameof(rope));
+ }
+ }
+
+ ///
+ /// Create a new rope with a slice of text removed.
+ ///
+ /// The rope to copy.
+ /// The position to start removing chars at.
+ /// How many chars to remove.
+ [Pure]
+ public static Node Delete(Node rope, long start, long length)
+ {
+ var (left, _) = Split(rope, start);
+ var (_, right) = Split(rope, start + length);
+
+ return Concat(left, right);
+ }
+
+ ///
+ /// Create a new rope with a given slice of text replaced with a new string.
+ ///
+ /// The rope to copy.
+ /// The position to start removing characters at, and insert the new text at.
+ /// How many characters from the original rope to remove.
+ /// The new text to insert at the start position.
+ [Pure]
+ public static Node ReplaceSubstring(Node rope, long start, long length, string text)
+ {
+ var (left, mid) = Split(rope, start);
+ var (_, right) = Split(mid, length);
+
+ return Concat(left, Concat(text, right));
+ }
+
+ ///
+ /// Try to fetch a at a certain position in the rune.
+ /// Fails if the given position is inside a surrogate pair.
+ ///
+ [Pure]
+ public static bool TryGetRuneAt(Node rope, long index, out Rune value)
+ {
+ var chr = Index(rope, index);
+ if (!char.IsSurrogate(chr))
+ {
+ value = new Rune(chr);
+ return true;
+ }
+
+ if (char.IsLowSurrogate(chr))
+ {
+ value = default;
+ return false;
+ }
+
+ // TODO: throws if a high surrogate is at the very end of the rope.
+ var lowChr = Index(rope, index + 1);
+ if (!char.IsLowSurrogate(lowChr))
+ {
+ value = default;
+ return false;
+ }
+
+ value = new Rune(chr, lowChr);
+ return true;
+ }
+
+ ///
+ /// Collapse the rope into a single string instance.
+ ///
+ /// The given rope is too large to fit in a single string.
+ [Pure]
+ public static string Collapse(Node rope)
+ {
+ var length = CalcTotalLength(rope);
+
+ return string.Create(checked((int)length), rope, static (span, node) =>
+ {
+ foreach (var leaf in CollectLeaves(node))
+ {
+ var text = leaf.Text;
+ text.CopyTo(span);
+ span = span[text.Length..];
+ }
+ });
+ }
+
+ ///
+ /// Collapse a substring of a rope into a single string instance.
+ ///
+ /// The rope to collapse part of.
+ /// The range of the substring to collapse.
+ /// The given rope is too large to fit in a single string.
+ [Pure]
+ public static string CollapseSubstring(Node rope, Range range)
+ {
+ // TODO: Optimize
+ return Collapse(rope)[range];
+ }
+
+ ///
+ /// Offset a cursor position in a rope to the left, skipping over the middle of surrogate pairs.
+ ///
+ [Pure]
+ public static long RuneShiftLeft(long index, Node rope)
+ {
+ index -= 1;
+ if (char.IsLowSurrogate(Index(rope, index)))
+ index -= 1;
+
+ return index;
+ }
+
+ ///
+ /// Offset a cursor position in a rope to the right, skipping over the middle of surrogate pairs.
+ ///
+ [Pure]
+ public static long RuneShiftRight(long index, Node rope)
+ {
+ index += 1;
+
+ // Before you confuse yourself on "shouldn't this be high surrogate since shifting left checks low"
+ // (Because yes, I did myself too a week after writing it)
+ // char.IsLowSurrogate(_text[_cursorPosition]) means "is the cursor between a surrogate pair"
+ // because we ALREADY moved.
+ if (char.IsLowSurrogate(Index(rope, index)))
+ index += 1;
+
+ return index;
+ }
+
+ ///
+ /// Returns true if the given rope is either null or empty (length 0).
+ ///
+ [Pure]
+ public static bool IsNullOrEmpty([NotNullWhen(false)] Node? rope)
+ {
+ if (rope == null)
+ return true;
+
+ return CalcTotalLength(rope) == 0;
+ }
+
+ ///
+ /// A nope in a rope. This is either a or a .
+ ///
+ public abstract class Node
+ {
+ public abstract long Weight { get; }
+
+ ///
+ /// The depth of the deepest leaf in this node tree. A leaf has depth 0, and a branch one above 1, etc...
+ ///
+ public abstract short Depth { get; }
+ }
+
+ ///
+ /// A leaf contains a string of text.
+ ///
+ [DebuggerDisplay("W: {Weight}, Text: {Text}")]
+ public sealed class Leaf : Node
+ {
+ public static readonly Leaf Empty = new("");
+
+ public string Text { get; }
+
+ public Leaf(string text)
+ {
+ Text = text;
+ }
+
+ public override long Weight => Text.Length;
+ public override short Depth => 0;
+ }
+
+ ///
+ /// A branch contains other nodes to the left and right.
+ ///
+ [DebuggerDisplay("W: {Weight}")]
+ public sealed class Branch : Node
+ {
+ public Node Left { get; }
+ public Node? Right { get; }
+ public override long Weight { get; }
+ public override short Depth { get; }
+
+ public Branch(Node left, Node? right)
+ {
+ Left = left;
+ Right = right;
+ Weight = CalcTotalLength(left);
+ Depth = checked((short)(Math.Max(left.Depth, right?.Depth ?? 0) + 1));
+ }
+ }
+}
diff --git a/Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs b/Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs
index b008af502..ddef0ff58 100644
--- a/Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs
+++ b/Robust.UnitTesting/Client/UserInterface/Controls/LineEditTest.cs
@@ -1,5 +1,7 @@
using Moq;
using NUnit.Framework;
+using Robust.Client.Graphics;
+using Robust.Client.Graphics.Clyde;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Input;
@@ -15,11 +17,13 @@ namespace Robust.UnitTesting.Client.UserInterface.Controls
public void Setup()
{
var uiMgr = new Mock();
+ var clyde = new ClydeHeadless();
IoCManager.InitThread();
IoCManager.Clear();
IoCManager.RegisterInstance(uiMgr.Object);
IoCManager.RegisterInstance(uiMgr.Object);
+ IoCManager.RegisterInstance(clyde);
IoCManager.BuildGraph();
}
diff --git a/Robust.UnitTesting/Client/UserInterface/Controls/TextEditSharedTest.cs b/Robust.UnitTesting/Client/UserInterface/Controls/TextEditSharedTest.cs
new file mode 100644
index 000000000..54e009d36
--- /dev/null
+++ b/Robust.UnitTesting/Client/UserInterface/Controls/TextEditSharedTest.cs
@@ -0,0 +1,68 @@
+using NUnit.Framework;
+using Robust.Client.UserInterface.Controls;
+
+namespace Robust.UnitTesting.Client.UserInterface.Controls;
+
+[TestFixture]
+[TestOf(typeof(TextEditShared))]
+[Parallelizable]
+internal sealed class TextEditSharedTest
+{
+ // @formatter:off
+ [Test]
+ [TestCase("foo bar baz", 0, ExpectedResult = 0)]
+ [TestCase("foo bar baz", 1, ExpectedResult = 0)]
+ [TestCase("foo bar baz", 4, ExpectedResult = 0)]
+ [TestCase("foo bar baz", 5, ExpectedResult = 4)]
+ [TestCase("foo bar baz", 8, ExpectedResult = 4)]
+ [TestCase("foo bar baz", 9, ExpectedResult = 8)]
+ [TestCase("foo +bar baz", 5, ExpectedResult = 4)]
+ [TestCase("foo +bar baz", 4, ExpectedResult = 0)]
+ [TestCase("foo +bar baz", 6, ExpectedResult = 5)]
+ [TestCase("foo +bar baz", 7, ExpectedResult = 5)]
+ [TestCase("Foo Bar Baz", 4, ExpectedResult = 0)]
+ [TestCase("Foo Bar Baz", 11, ExpectedResult = 8)]
+ [TestCase("Foo[Bar[Baz", 3, ExpectedResult = 0)]
+ [TestCase("Foo[Bar[Baz", 4, ExpectedResult = 3)]
+ [TestCase("Foo^Bar^Baz", 3, ExpectedResult = 0)]
+ [TestCase("Foo^Bar^Baz", 5, ExpectedResult = 3)]
+ [TestCase("Foo^^^Bar^Baz", 9, ExpectedResult = 3)]
+ [TestCase("^^^ ^^^", 7, ExpectedResult = 0)]
+ [TestCase("^^^ ^^^", 13, ExpectedResult = 7)]
+ // @formatter:on
+ public int TestPrevWordPosition(string str, int cursor)
+ {
+ // For my sanity.
+ str = str.Replace("^", "👏");
+
+ return TextEditShared.PrevWordPosition(str, cursor);
+ }
+
+ [Test]
+ // @formatter:off
+ [TestCase("foo bar baz", 11, ExpectedResult = 11)]
+ [TestCase("foo bar baz", 0, ExpectedResult = 4 )]
+ [TestCase("foo bar baz", 1, ExpectedResult = 4 )]
+ [TestCase("foo bar baz", 3, ExpectedResult = 4 )]
+ [TestCase("foo bar baz", 4, ExpectedResult = 8 )]
+ [TestCase("foo bar baz", 5, ExpectedResult = 8 )]
+ [TestCase("Foo Bar Baz", 0, ExpectedResult = 4 )]
+ [TestCase("Foo Bar Baz", 8, ExpectedResult = 11)]
+ [TestCase("foo +bar baz", 0, ExpectedResult = 4 )]
+ [TestCase("foo +bar baz", 4, ExpectedResult = 5 )]
+ [TestCase("Foo[Bar[Baz", 0, ExpectedResult = 3 )]
+ [TestCase("Foo[Bar[Baz", 3, ExpectedResult = 4 )]
+ [TestCase("Foo^Bar^Baz", 0, ExpectedResult = 3 )]
+ [TestCase("Foo^Bar^Baz", 3, ExpectedResult = 5 )]
+ [TestCase("Foo^^^Bar^Baz", 3, ExpectedResult = 9 )]
+ [TestCase("^^^ ^^^", 0, ExpectedResult = 7 )]
+ [TestCase("^^^ ^^^", 7, ExpectedResult = 13)]
+ // @formatter:on
+ public int TestNextWordPosition(string str, int cursor)
+ {
+ // For my sanity.
+ str = str.Replace("^", "👏");
+
+ return TextEditShared.NextWordPosition(str, cursor);
+ }
+}
diff --git a/Robust.UnitTesting/GameControllerDummy.cs b/Robust.UnitTesting/GameControllerDummy.cs
index 7723e1b77..41d5eb8eb 100644
--- a/Robust.UnitTesting/GameControllerDummy.cs
+++ b/Robust.UnitTesting/GameControllerDummy.cs
@@ -49,7 +49,7 @@ namespace Robust.UnitTesting
{
}
- public void TextEntered(TextEventArgs textEvent)
+ public void TextEntered(TextEnteredEventArgs textEnteredEvent)
{
}
diff --git a/Robust.UnitTesting/Shared/Utility/FormattedMessage_Test.cs b/Robust.UnitTesting/Shared/Utility/FormattedMessage_Test.cs
index 918972bf5..4527e78b2 100644
--- a/Robust.UnitTesting/Shared/Utility/FormattedMessage_Test.cs
+++ b/Robust.UnitTesting/Shared/Utility/FormattedMessage_Test.cs
@@ -71,5 +71,19 @@ namespace Robust.UnitTesting.Shared.Utility
var message = FormattedMessage.FromMarkup(text);
Assert.That(message.ToMarkup(), NUnit.Framework.Is.EqualTo(text));
}
+
+ [Test]
+ [TestCase("Foo")]
+ [TestCase("[color=#FF000000]Foo[/color]")]
+ [TestCase("[color=#00FF00FF]Foo[/color]bar")]
+ [TestCase("honk honk [color=#00FF00FF]Foo[/color]bar")]
+ public static void TestEnumerateRunes(string text)
+ {
+ var message = FormattedMessage.FromMarkup(text);
+
+ Assert.That(
+ message.EnumerateRunes(),
+ Is.EquivalentTo(message.ToString().EnumerateRunes()));
+ }
}
}
diff --git a/Robust.UnitTesting/Shared/Utility/TextRope_Test.cs b/Robust.UnitTesting/Shared/Utility/TextRope_Test.cs
new file mode 100644
index 000000000..aad8130e6
--- /dev/null
+++ b/Robust.UnitTesting/Shared/Utility/TextRope_Test.cs
@@ -0,0 +1,147 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using NUnit.Framework;
+using Robust.Shared.Utility;
+
+namespace Robust.UnitTesting.Shared.Utility;
+
+[TestFixture]
+[TestOf(typeof(Rope))]
+[Parallelizable(ParallelScope.All)]
+[SuppressMessage("ReSharper", "AccessToStaticMemberViaDerivedType")]
+public static class TextRope_Test
+{
+ [Test]
+ public static void TestCalcWeight()
+ {
+ // Just using the example from Wikipedia:
+ // https://commons.wikimedia.org/wiki/File:Vector_Rope_example.svg
+
+ BuildExample(out var tree);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(tree.NodeN.Weight, Is.EqualTo(6));
+ Assert.That(tree.NodeM.Weight, Is.EqualTo(1));
+ Assert.That(tree.NodeK.Weight, Is.EqualTo(4));
+ Assert.That(tree.NodeJ.Weight, Is.EqualTo(2));
+ Assert.That(tree.NodeF.Weight, Is.EqualTo(3));
+ Assert.That(tree.NodeE.Weight, Is.EqualTo(6));
+
+ Assert.That(tree.NodeH.Weight, Is.EqualTo(1));
+ Assert.That(tree.NodeG.Weight, Is.EqualTo(2));
+
+ Assert.That(tree.NodeC.Weight, Is.EqualTo(6));
+ Assert.That(tree.NodeD.Weight, Is.EqualTo(6));
+
+ Assert.That(tree.NodeB.Weight, Is.EqualTo(9));
+
+ Assert.That(tree.NodeA.Weight, Is.EqualTo(22));
+ });
+ }
+
+ [Test]
+ public static void TestCollect()
+ {
+ var tree = BuildExample(out _);
+ var leaves = Rope.CollectLeaves(tree).Select(x => x.Text).ToArray();
+
+ Assert.That(leaves, Is.EquivalentTo(new[]
+ {
+ "Hello ", "my ", "na", "me i", "s", " Simon"
+ }));
+ }
+
+ [Test]
+ public static void TestCollectReverse()
+ {
+ var tree = BuildExample(out _);
+ var leaves = Rope.CollectLeavesReverse(tree).Select(x => x.Text).ToArray();
+
+ Assert.That(leaves, Is.EquivalentTo(new[]
+ {
+ "Hello ", "my ", "na", "me i", "s", " Simon"
+ }.Reverse()));
+ }
+
+ [Test]
+ public static void TestCollapse()
+ {
+ var tree = BuildExample(out _);
+
+ Assert.That(Rope.Collapse(tree), Is.EqualTo("Hello my name is Simon"));
+ }
+
+ [Test]
+ public static void TestSplit()
+ {
+ var tree = BuildExample(out _);
+ var (left, right) = Rope.Split(tree, 7);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(Rope.Collapse(left), Is.EqualTo("Hello m"));
+ Assert.That(Rope.Collapse(right), Is.EqualTo("y name is Simon"));
+ });
+ }
+
+ [Test]
+ public static void TestDelete()
+ {
+ var tree = BuildExample(out _);
+
+ tree = Rope.Delete(tree, 2, 11);
+ Assert.That(Rope.Collapse(tree), Is.EqualTo("He is Simon"));
+ }
+
+ [Test]
+ public static void TestEnumerateRunesReverseSub()
+ {
+ var tree = BuildExample(out _);
+
+ var runes = Rope.EnumerateRunesReverse(tree, 10);
+ Assert.That(
+ runes,
+ Is.EquivalentTo("Hello my n".EnumerateRunes().Reverse()));
+ }
+
+ private static Rope.Node BuildExample(out ExampleTree tree)
+ {
+ tree = default;
+
+ tree.NodeN = new Rope.Leaf(" Simon");
+ tree.NodeM = new Rope.Leaf("s");
+ tree.NodeK = new Rope.Leaf("me i");
+ tree.NodeJ = new Rope.Leaf("na");
+ tree.NodeF = new Rope.Leaf("my ");
+ tree.NodeE = new Rope.Leaf("Hello ");
+
+ tree.NodeH = new Rope.Branch(tree.NodeM, tree.NodeN);
+ tree.NodeG = new Rope.Branch(tree.NodeJ, tree.NodeK);
+
+ tree.NodeC = new Rope.Branch(tree.NodeE, tree.NodeF);
+ tree.NodeD = new Rope.Branch(tree.NodeG, tree.NodeH);
+
+ tree.NodeB = new Rope.Branch(tree.NodeC, tree.NodeD);
+
+ tree.NodeA = new Rope.Branch(tree.NodeB, null);
+
+ return tree.NodeA;
+ }
+
+ public struct ExampleTree
+ {
+ public Rope.Node NodeN;
+ public Rope.Node NodeM;
+ public Rope.Node NodeK;
+ public Rope.Node NodeJ;
+ public Rope.Node NodeF;
+ public Rope.Node NodeE;
+ public Rope.Node NodeH;
+ public Rope.Node NodeG;
+ public Rope.Node NodeC;
+ public Rope.Node NodeD;
+ public Rope.Node NodeB;
+ public Rope.Node NodeA;
+ }
+}