diff --git a/.github/workflows/publish-client.yml b/.github/workflows/publish-client.yml
index 4767740a2ff..5319e4bec30 100644
--- a/.github/workflows/publish-client.yml
+++ b/.github/workflows/publish-client.yml
@@ -33,10 +33,10 @@ jobs:
mkdir "release/${{ steps.parse_version.outputs.version }}"
mv release/*.zip "release/${{ steps.parse_version.outputs.version }}"
- - name: Upload files to Suns
+ - name: Upload files to Dropwig
uses: appleboy/scp-action@master
with:
- host: suns.spacestation14.com
+ host: dropwig.playss14.com
username: robust-build-push
key: ${{ secrets.CENTCOMM_ROBUST_BUILDS_PUSH_KEY }}
source: "release/${{ steps.parse_version.outputs.version }}"
@@ -46,7 +46,7 @@ jobs:
- name: Update manifest JSON
uses: appleboy/ssh-action@master
with:
- host: suns.spacestation14.com
+ host: dropwig.playss14.com
username: robust-build-push
key: ${{ secrets.CENTCOMM_ROBUST_BUILDS_PUSH_KEY }}
script: /home/robust-build-push/push.ps1 ${{ steps.parse_version.outputs.version }}
diff --git a/.gitignore b/.gitignore
index 365c9e0ec40..781d10b905c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,3 +78,6 @@ MSBuild/Robust.Custom.targets
release/
Robust.Docfx/*-site
Robust.Docfx/api
+
+# C# Dev Kit cache file
+*.lscache
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 69000ce2613..92e4a7258df 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -48,7 +48,7 @@
-
+
diff --git a/Lidgren.Network/Lidgren.Network b/Lidgren.Network/Lidgren.Network
index 1d85b82e058..726dd552a4b 160000
--- a/Lidgren.Network/Lidgren.Network
+++ b/Lidgren.Network/Lidgren.Network
@@ -1 +1 @@
-Subproject commit 1d85b82e058101b7ebd60cc8883af5359e4c263a
+Subproject commit 726dd552a4b104fb2b701848705f1250a073f060
diff --git a/MSBuild/Robust.Engine.Version.props b/MSBuild/Robust.Engine.Version.props
index bac96a1d378..a27c1563a11 100644
--- a/MSBuild/Robust.Engine.Version.props
+++ b/MSBuild/Robust.Engine.Version.props
@@ -1,4 +1,8 @@
-
-
- 277.0.0
-
+
+
+
+
+ 277.2.1-leak
+
+
+
diff --git a/NetSerializer b/NetSerializer
index 61b47fbbbd1..c32b75671ac 160000
--- a/NetSerializer
+++ b/NetSerializer
@@ -1 +1 @@
-Subproject commit 61b47fbbbd15af3369e80d670e2959d83e826e2c
+Subproject commit c32b75671acd1c52be7fe2674de0c5a3b1ddeff0
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index e60a7b1ed48..570099d67be 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -53,6 +53,63 @@ END TEMPLATE-->
*None yet*
+## 277.2.1-leak
+
+## 277.2.1
+
+### Bugfixes
+
+* Fixed a bug in Lidgren.Network HandleReleasedFragment that could cause out of memory errors.
+
+
+## 277.2.0
+
+### New features
+
+* Local and guest trust scores are now assigned via the `auth.localtrust` and `auth.guesttrust` CVars.
+
+### Bugfixes
+
+* Fixed exceptions related to WebSocket transfer system.
+
+
+## 277.1.0
+
+### New features
+
+* Console commands can now be "hidden" by prefixing them with `_`.
+* Add `ESCAPE()` and `ESCAPE-PARAM()` localization functions, for escaping text for markup formatting.
+* The `[cmdlink /]` tag can now have a tooltip specified with the optional "title" attribute.
+* Added `CommandParsing.EscapeCommand()` for *formatting* command strings easily.
+* Added `IUserInterfaceManager.GetRootForMouse()`.
+* Added `Popup.OpenAtCursor()`.
+* Added `FormattedStringBuilder` for safely constructing markup with code.
+* Added `VBox` and `HBox` convenience types for more concisely construct `BoxContainer`s.
+* Added `IsLocal` to `NetUserData`.
+* Add `SharedMapSystem.GetFilledTileCount()`
+* Add a `track` style property for scroll bars.
+* Added `ScrollLock` key.
+
+### Bugfixes
+
+* Fixes override properties in `WrapContainer` not being respected properly.
+* Fix `BoxContainer.SeparationOverride` not being respected properly and not invalidating layout.
+* Fixed swapped arguments being passed through in various `FindGridsIntersecting` overloads.
+* Fixed a doc comment in `LocalizationManager`.
+
+### Other
+
+* Add Pure attributes to the EntityLookup bounds methods
+* Bump `Robust.Natives` to `0.2.5`.
+* Minor performance improvement in `IsHardCollidable()`.
+* Remove an outdated paragraph from `[DependencyAttribute]` documentation related to `readonly` fields.
+* More stock controls use alternative cursor shapes where appropriate.
+* Minor performance improvement to audio loading.
+
+### Internal
+
+* Added `.lscache` to `.gitignore`.
+
## 277.0.0
diff --git a/Resources/Locale/en-US/input.ftl b/Resources/Locale/en-US/input.ftl
index de7d1f83292..4b8e1f72bf3 100644
--- a/Resources/Locale/en-US/input.ftl
+++ b/Resources/Locale/en-US/input.ftl
@@ -69,6 +69,7 @@ input-key-MouseButton7 = Mouse 7
input-key-MouseButton8 = Mouse 8
input-key-MouseButton9 = Mouse 9
input-key-CapsLock = Caps Lock
+input-key-ScrollLock = Scroll Lock
input-key-LSystem-win = Left Win
input-key-RSystem-win = Right Win
diff --git a/Resources/Locale/pt-BR/input.ftl b/Resources/Locale/pt-BR/input.ftl
index 90c5ea153a9..d387a67d338 100644
--- a/Resources/Locale/pt-BR/input.ftl
+++ b/Resources/Locale/pt-BR/input.ftl
@@ -44,6 +44,7 @@ input-key-MouseButton7 = Mouse 7
input-key-MouseButton8 = Mouse 8
input-key-MouseButton9 = Mouse 9
input-key-CapsLock = Caps Lock
+input-key-ScrollLock = Scroll Lock
input-key-LSystem-win = Left Win
input-key-RSystem-win = Right Win
diff --git a/Robust.Client/Console/ClientConsoleHost.Completions.cs b/Robust.Client/Console/ClientConsoleHost.Completions.cs
index 7bda1199f44..6195a952b2e 100644
--- a/Robust.Client/Console/ClientConsoleHost.Completions.cs
+++ b/Robust.Client/Console/ClientConsoleHost.Completions.cs
@@ -34,7 +34,7 @@ private Task CalcCompletions(List args, string argStr,
// Typing out command name, handle this ourselves.
var cmdOptions = CompletionResult.FromOptions(
AvailableCommands.Values
- .Where(c => CanExecute(c.Command))
+ .Where(c => CanExecute(c.Command) && !IsCommandHidden(c))
.OrderBy(c => c.Command)
.Select(c => new CompletionOption(c.Command, c.Description)));
diff --git a/Robust.Client/Graphics/Clyde/Windowing/Sdl3.Key.cs b/Robust.Client/Graphics/Clyde/Windowing/Sdl3.Key.cs
index 18e9f79dd2f..c5b75daa2fe 100644
--- a/Robust.Client/Graphics/Clyde/Windowing/Sdl3.Key.cs
+++ b/Robust.Client/Graphics/Clyde/Windowing/Sdl3.Key.cs
@@ -204,6 +204,7 @@ static Sdl3WindowingImpl()
MapKey(SC.SDL_SCANCODE_F24, Key.F24);
MapKey(SC.SDL_SCANCODE_PAUSE, Key.Pause);
MapKey(SC.SDL_SCANCODE_CAPSLOCK, Key.CapsLock);
+ MapKey(SC.SDL_SCANCODE_SCROLLLOCK, Key.ScrollLock);
var keyMapReverse = new Dictionary();
diff --git a/Robust.Client/Input/InputDevices.cs b/Robust.Client/Input/InputDevices.cs
index abed7da740b..fb41f35dd25 100644
--- a/Robust.Client/Input/InputDevices.cs
+++ b/Robust.Client/Input/InputDevices.cs
@@ -174,6 +174,7 @@ public enum Key : byte
Pause,
World1,
CapsLock,
+ ScrollLock
}
public static bool IsMouseKey(this Key key)
diff --git a/Robust.Client/Network/Transfer/ClientTransferManager.cs b/Robust.Client/Network/Transfer/ClientTransferManager.cs
index 781679d9e63..4f3af82a39b 100644
--- a/Robust.Client/Network/Transfer/ClientTransferManager.cs
+++ b/Robust.Client/Network/Transfer/ClientTransferManager.cs
@@ -46,6 +46,13 @@ public void Initialize()
_netManager.RegisterNetMessage(RxTransferInit, NetMessageAccept.Client | NetMessageAccept.Handshake);
_netManager.RegisterNetMessage();
_netManager.RegisterNetMessage(RxTransferData, NetMessageAccept.Client | NetMessageAccept.Handshake);
+ _netManager.Disconnect += OnNetDisconnect;
+ }
+
+ private void OnNetDisconnect(object? sender, NetDisconnectedArgs e)
+ {
+ _transferImpl?.Dispose();
+ _transferImpl = null;
}
private async void RxTransferInit(MsgTransferInit message)
diff --git a/Robust.Client/ResourceManagement/ResourceTypes/AudioResource.cs b/Robust.Client/ResourceManagement/ResourceTypes/AudioResource.cs
index aaf759b795c..ece658f7b7f 100644
--- a/Robust.Client/ResourceManagement/ResourceTypes/AudioResource.cs
+++ b/Robust.Client/ResourceManagement/ResourceTypes/AudioResource.cs
@@ -40,12 +40,12 @@ public override void Load(IDependencyCollection dependencies, ResPath path)
seekableStream.Seek(0, SeekOrigin.Begin);
var audioManager = dependencies.Resolve();
- if (signature[..OggSignature.Length].SequenceEqual(OggSignature))
+ if (signature.AsSpan()[..OggSignature.Length].SequenceEqual(OggSignature))
{
AudioStream = audioManager.LoadAudioOggVorbis(seekableStream, path.ToString());
}
- else if (signature[..RiffSignature.Length].SequenceEqual(RiffSignature)
- && signature[WavSignatureStart..MaxSignatureLength].SequenceEqual(WavSignature))
+ else if (signature.AsSpan()[..RiffSignature.Length].SequenceEqual(RiffSignature)
+ && signature.AsSpan()[WavSignatureStart..MaxSignatureLength].SequenceEqual(WavSignature))
{
AudioStream = audioManager.LoadAudioWav(seekableStream, path.ToString());
}
diff --git a/Robust.Client/UserInterface/Controls/BaseButton.cs b/Robust.Client/UserInterface/Controls/BaseButton.cs
index d590b9619c7..e8a6856166f 100644
--- a/Robust.Client/UserInterface/Controls/BaseButton.cs
+++ b/Robust.Client/UserInterface/Controls/BaseButton.cs
@@ -90,6 +90,7 @@ public bool Disabled
if (old != value)
{
+ DefaultCursorShape = Disabled ? CursorShape.NotAllowed : CursorShape.Pointer;
DrawModeChanged();
}
}
@@ -234,6 +235,7 @@ public bool MuteSounds
protected BaseButton()
{
MouseFilter = MouseFilterMode.Stop;
+ DefaultCursorShape = Disabled ? CursorShape.NotAllowed : CursorShape.Pointer;
}
protected virtual void DrawModeChanged()
diff --git a/Robust.Client/UserInterface/Controls/HBox.cs b/Robust.Client/UserInterface/Controls/HBox.cs
new file mode 100644
index 00000000000..c9ce4110c07
--- /dev/null
+++ b/Robust.Client/UserInterface/Controls/HBox.cs
@@ -0,0 +1,6 @@
+namespace Robust.Client.UserInterface.Controls;
+
+///
+/// Convenience type to describe a horizontal .
+///
+public sealed class HBox : BoxContainer;
diff --git a/Robust.Client/UserInterface/Controls/ItemList.cs b/Robust.Client/UserInterface/Controls/ItemList.cs
index b727287bc23..8af6fe965f1 100644
--- a/Robust.Client/UserInterface/Controls/ItemList.cs
+++ b/Robust.Client/UserInterface/Controls/ItemList.cs
@@ -595,16 +595,42 @@ protected internal override void MouseMove(GUIMouseMoveEventArgs args)
{
base.MouseMove(args);
+ DefaultCursorShape = CursorShape.Arrow;
+
for (var idx = 0; idx < _itemList.Count; idx++)
{
var item = _itemList[idx];
if (item.Region == null) continue;
if (!item.Region.Value.Contains(args.RelativePosition)) continue;
+
+ if (SelectMode != ItemListSelectMode.None)
+ {
+ if (item.Disabled)
+ {
+ DefaultCursorShape = CursorShape.NotAllowed;
+ }
+ else if (item.Selectable)
+ {
+ DefaultCursorShape = CursorShape.Pointer;
+ }
+ else
+ {
+ DefaultCursorShape = CursorShape.Arrow;
+ }
+ }
+
OnItemHover?.Invoke(new ItemListHoverEventArgs(idx, this));
break;
}
}
+ protected internal override void MouseExited()
+ {
+ base.MouseExited();
+
+ DefaultCursorShape = CursorShape.Arrow;
+ }
+
protected internal override void MouseWheel(GUIMouseWheelEventArgs args)
{
base.MouseWheel(args);
diff --git a/Robust.Client/UserInterface/Controls/MenuBar.cs b/Robust.Client/UserInterface/Controls/MenuBar.cs
index ede2d89aafd..536a88ca071 100644
--- a/Robust.Client/UserInterface/Controls/MenuBar.cs
+++ b/Robust.Client/UserInterface/Controls/MenuBar.cs
@@ -235,6 +235,7 @@ public abstract class MenuTopButton : PanelContainer
public MenuTopButton(Menu menu)
{
MouseFilter = MouseFilterMode.Pass;
+ DefaultCursorShape = CursorShape.Pointer;
ChildMenu = menu;
}
diff --git a/Robust.Client/UserInterface/Controls/Popup.cs b/Robust.Client/UserInterface/Controls/Popup.cs
index 2b4f363ea7e..a2223b3d40d 100644
--- a/Robust.Client/UserInterface/Controls/Popup.cs
+++ b/Robust.Client/UserInterface/Controls/Popup.cs
@@ -30,6 +30,29 @@ public Popup()
public bool CloseOnEscape { get; set; } = true;
+ private bool _autoOrphan;
+
+ ///
+ /// Opens the popup at the location of the mouse.
+ ///
+ ///
+ ///
+ /// The popup is placed in the modal root, and is automatically sized.
+ ///
+ ///
+ /// The popup *automatically* gets removed from the popup root when it is hidden again.
+ /// Do not remove it manually!
+ ///
+ ///
+ public void OpenAtMouse()
+ {
+ _autoOrphan = true;
+ var root = UserInterfaceManager.GetRootForMouse();
+ root.ModalRoot.AddChild(this);
+
+ Open(UIBox2.FromDimensions(UserInterfaceManager.MousePositionScaled.Position, Vector2.One));
+ }
+
public virtual void Open(UIBox2? box = null, Vector2? altPos = null, Vector2? altPosUp = null)
{
if (Visible)
@@ -72,6 +95,12 @@ protected internal override void ModalRemoved()
Visible = false;
OnPopupHide?.Invoke();
+
+ if (_autoOrphan)
+ {
+ Orphan();
+ _autoOrphan = false;
+ }
}
protected override Vector2 MeasureOverride(Vector2 availableSize)
diff --git a/Robust.Client/UserInterface/Controls/ScrollBar.cs b/Robust.Client/UserInterface/Controls/ScrollBar.cs
index d6abd432836..0aa25ed45a6 100644
--- a/Robust.Client/UserInterface/Controls/ScrollBar.cs
+++ b/Robust.Client/UserInterface/Controls/ScrollBar.cs
@@ -9,6 +9,7 @@ namespace Robust.Client.UserInterface.Controls
{
public abstract class ScrollBar : Range
{
+ public const string StylePropertyTrack = "track";
public const string StylePropertyGrabber = "grabber";
public const string StylePseudoClassHover = "hover";
public const string StylePseudoClassGrabbed = "grabbed";
@@ -45,6 +46,7 @@ protected ScrollBar(OrientationMode orientation)
ReservesSpace = true;
_orientation = orientation;
+ DefaultCursorShape = CursorShape.Pointer;
}
public bool IsAtEnd
@@ -80,6 +82,9 @@ protected override void FrameUpdate(FrameEventArgs args)
protected internal override void Draw(DrawingHandleScreen handle)
{
+ var trackStyle = _getTrackStyleBox();
+ trackStyle?.Draw(handle, PixelSizeBox, UIScale);
+
var styleBox = _getGrabberStyleBox();
styleBox?.Draw(handle, _getGrabberBox(), UIScale);
}
@@ -127,11 +132,19 @@ protected internal override void KeyBindUp(GUIBoundKeyEventArgs args)
protected internal override void MouseMove(GUIMouseMoveEventArgs args)
{
+ DefaultCursorShape = CursorShape.Arrow;
+
+ if (_isHovered || _grabData != null)
+ {
+ DefaultCursorShape = CursorShape.Pointer;
+ }
+
if (_grabData == null)
{
var box = _getGrabberBox();
_isHovered = box.Contains(args.RelativePixelPosition);
_updatePseudoClass();
+
return;
}
@@ -193,6 +206,12 @@ private float _getGrabberBoxMinSize()
return null;
}
+ [System.Diagnostics.Contracts.Pure]
+ private StyleBox? _getTrackStyleBox()
+ {
+ return StylePropertyDefault(StylePropertyTrack, null);
+ }
+
[System.Diagnostics.Contracts.Pure]
private float _getOrientationSize()
{
diff --git a/Robust.Client/UserInterface/Controls/Slider.cs b/Robust.Client/UserInterface/Controls/Slider.cs
index f2f9c955257..bea5776f6a2 100644
--- a/Robust.Client/UserInterface/Controls/Slider.cs
+++ b/Robust.Client/UserInterface/Controls/Slider.cs
@@ -81,6 +81,7 @@ public StyleBox? GrabberStyleBoxOverride
public Slider()
{
MouseFilter = MouseFilterMode.Stop;
+ DefaultCursorShape = CursorShape.Pointer;
AddChild(new LayoutContainer
{
diff --git a/Robust.Client/UserInterface/Controls/SplitContainer.cs b/Robust.Client/UserInterface/Controls/SplitContainer.cs
index 4b8baa31529..6b487cb1904 100644
--- a/Robust.Client/UserInterface/Controls/SplitContainer.cs
+++ b/Robust.Client/UserInterface/Controls/SplitContainer.cs
@@ -176,6 +176,7 @@ public SplitOrientation Orientation
set
{
_orientation = value;
+ _splitDragArea.DefaultCursorShape = Vertical ? CursorShape.VResize : CursorShape.HResize;
InvalidateMeasure();
}
}
@@ -185,7 +186,7 @@ public SplitContainer()
MouseFilter = MouseFilterMode.Stop;
AddChild(_splitDragArea);
_splitDragArea.Visible = _resizeMode != SplitResizeMode.NotResizable;
- _splitDragArea.DefaultCursorShape = Vertical ? CursorShape.VResize : CursorShape.HResize;
+ _splitDragArea.DefaultCursorShape = Vertical ? CursorShape.VResize : CursorShape.HResize;
_splitDragArea.OnMouseUp += StopDragging;
_splitDragArea.OnMouseDown += StartDragging;
_splitDragArea.OnMouseMove += OnMove;
diff --git a/Robust.Client/UserInterface/Controls/TabContainer.cs b/Robust.Client/UserInterface/Controls/TabContainer.cs
index 5e0f729addc..df4cba4326f 100644
--- a/Robust.Client/UserInterface/Controls/TabContainer.cs
+++ b/Robust.Client/UserInterface/Controls/TabContainer.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Shared.Input;
@@ -325,14 +326,49 @@ protected internal override void KeyBindDown(GUIBoundKeyEventArgs args)
args.Handle();
+ if (!TryGetHoveredTab(args.RelativePixelPosition, out var index))
+ {
+ return;
+ }
+
+ CurrentTab = index.Value;
+ }
+
+ protected internal override void MouseMove(GUIMouseMoveEventArgs args)
+ {
+ base.MouseMove(args);
+
+ DefaultCursorShape = TryGetHoveredTab(args.RelativePixelPosition, out _)
+ ? CursorShape.Pointer
+ : CursorShape.Arrow;
+ }
+
+ protected internal override void MouseExited()
+ {
+ base.MouseExited();
+
+ DefaultCursorShape = CursorShape.Arrow;
+ }
+
+ private bool TryGetHoveredTab(Vector2 position, [NotNullWhen(true)] out int? index)
+ {
+ index = null;
+
+ if (!TabsVisible || position.Y < 0 || position.Y > _enclosingTabHeight)
+ {
+ return false;
+ }
+
foreach (var box in _tabBoxes)
{
- if (box.Bounding.Contains(args.RelativePixelPosition))
+ if (box.Bounding.Contains(position))
{
- CurrentTab = box.Index;
- return;
+ index = box.Index;
+ return true;
}
}
+
+ return false;
}
[System.Diagnostics.Contracts.Pure]
diff --git a/Robust.Client/UserInterface/Controls/TextEdit.cs b/Robust.Client/UserInterface/Controls/TextEdit.cs
index ccba4700082..9f67fe32515 100644
--- a/Robust.Client/UserInterface/Controls/TextEdit.cs
+++ b/Robust.Client/UserInterface/Controls/TextEdit.cs
@@ -100,7 +100,7 @@ public TextEdit()
CanKeyboardFocus = true;
KeyboardFocusOnClick = true;
MouseFilter = MouseFilterMode.Stop;
- DefaultCursorShape = CursorShape.IBeam;
+ DefaultCursorShape = Editable ? CursorShape.IBeam : CursorShape.NotAllowed;
}
///
@@ -171,7 +171,7 @@ public bool Editable
set
{
_editable = value;
- DefaultCursorShape = _editable ? CursorShape.IBeam : CursorShape.Arrow;
+ DefaultCursorShape = _editable ? CursorShape.IBeam : CursorShape.NotAllowed;
UpdatePseudoClass();
}
}
diff --git a/Robust.Client/UserInterface/Controls/Tree.cs b/Robust.Client/UserInterface/Controls/Tree.cs
index 8f9585fbbdb..2f982e49de0 100644
--- a/Robust.Client/UserInterface/Controls/Tree.cs
+++ b/Robust.Client/UserInterface/Controls/Tree.cs
@@ -106,6 +106,22 @@ protected internal override void KeyBindDown(GUIBoundKeyEventArgs args)
}
}
+ protected internal override void MouseMove(GUIMouseMoveEventArgs args)
+ {
+ base.MouseMove(args);
+
+ DefaultCursorShape = _tryFindItemAtPosition(args.RelativePixelPosition)?.Selectable == true
+ ? CursorShape.Pointer
+ : CursorShape.Arrow;
+ }
+
+ protected internal override void MouseExited()
+ {
+ base.MouseExited();
+
+ DefaultCursorShape = CursorShape.Arrow;
+ }
+
private Item? _tryFindItemAtPosition(Vector2 position)
{
var font = _getFont();
diff --git a/Robust.Client/UserInterface/Controls/VBox.cs b/Robust.Client/UserInterface/Controls/VBox.cs
new file mode 100644
index 00000000000..1476f749b65
--- /dev/null
+++ b/Robust.Client/UserInterface/Controls/VBox.cs
@@ -0,0 +1,12 @@
+namespace Robust.Client.UserInterface.Controls;
+
+///
+/// Convenience type to describe a vertical .
+///
+public sealed class VBox : BoxContainer
+{
+ public VBox()
+ {
+ Orientation = LayoutOrientation.Vertical;
+ }
+}
diff --git a/Robust.Client/UserInterface/IUserInterfaceManager.cs b/Robust.Client/UserInterface/IUserInterfaceManager.cs
index 0ad4eb29997..271b2df7485 100644
--- a/Robust.Client/UserInterface/IUserInterfaceManager.cs
+++ b/Robust.Client/UserInterface/IUserInterfaceManager.cs
@@ -167,6 +167,14 @@ public partial interface IUserInterfaceManager
/// Exists so that control don't have to inject dependencies or otherwise obtain an instance just to log errors.
///
ISawmill ControlSawmill { get; }
+
+ ///
+ /// Get the UI root responsible for the current mouse position.
+ ///
+ ///
+ /// This is useful to open popups or similar on the current active window.
+ ///
+ UIRoot GetRootForMouse();
}
public readonly struct PostDrawUIRootEventArgs
diff --git a/Robust.Client/UserInterface/RichText/CommandLinkTag.cs b/Robust.Client/UserInterface/RichText/CommandLinkTag.cs
index 743ab15f889..78c213f3fad 100644
--- a/Robust.Client/UserInterface/RichText/CommandLinkTag.cs
+++ b/Robust.Client/UserInterface/RichText/CommandLinkTag.cs
@@ -36,6 +36,9 @@ public bool TryCreateControl(MarkupNode node, [NotNullWhen(true)] out Control? c
label.OnMouseExited += _ => label.FontColorOverride = Color.LightBlue;
label.OnKeyBindDown += args => OnKeybindDown(args, command);
+ if (node.Attributes.TryGetValue("title", out var titleArg))
+ label.ToolTip = titleArg.StringValue;
+
control = label;
return true;
}
diff --git a/Robust.Client/UserInterface/UserInterfaceManager.cs b/Robust.Client/UserInterface/UserInterfaceManager.cs
index 942e2f5ffef..57c86c35185 100644
--- a/Robust.Client/UserInterface/UserInterfaceManager.cs
+++ b/Robust.Client/UserInterface/UserInterfaceManager.cs
@@ -492,5 +492,20 @@ public void HoverSound()
{
ClearWindows();
}
+
+ public UIRoot GetRootForMouse()
+ {
+ var pos = _inputManager.MouseScreenPosition;
+
+ foreach (var root in _roots)
+ {
+ if (root.Window.Id == pos.Window)
+ {
+ return root;
+ }
+ }
+
+ return RootControl;
+ }
}
}
diff --git a/Robust.LoaderApi b/Robust.LoaderApi
index 5b467d11005..2b6a7db8d85 160000
--- a/Robust.LoaderApi
+++ b/Robust.LoaderApi
@@ -1 +1 @@
-Subproject commit 5b467d11005071f420435417927901d11947d5fb
+Subproject commit 2b6a7db8d85166b345302e446a8492a213fc92aa
diff --git a/Robust.Server/Network/Transfer/ServerTransferImplWebSocket.cs b/Robust.Server/Network/Transfer/ServerTransferImplWebSocket.cs
index 641b960f3a0..6541aa15374 100644
--- a/Robust.Server/Network/Transfer/ServerTransferImplWebSocket.cs
+++ b/Robust.Server/Network/Transfer/ServerTransferImplWebSocket.cs
@@ -118,5 +118,7 @@ public async Task HandleApiRequest(NetUserId userId, IStatusHandlerContext conte
public override void Dispose()
{
_connectTcs.TrySetCanceled();
+
+ base.Dispose();
}
}
diff --git a/Robust.Server/runtimeconfig.template.json b/Robust.Server/runtimeconfig.template.json
new file mode 100644
index 00000000000..1cf377784bd
--- /dev/null
+++ b/Robust.Server/runtimeconfig.template.json
@@ -0,0 +1,5 @@
+{
+ "configProperties": {
+ "System.GC.ConserveMemory": 5
+ }
+}
diff --git a/Robust.Shared.Tests/RichText/FormattedStringBuilderTest.cs b/Robust.Shared.Tests/RichText/FormattedStringBuilderTest.cs
new file mode 100644
index 00000000000..431cd925222
--- /dev/null
+++ b/Robust.Shared.Tests/RichText/FormattedStringBuilderTest.cs
@@ -0,0 +1,255 @@
+using System.Text;
+using NUnit.Framework;
+using Robust.Shared.Maths;
+using Robust.Shared.RichText;
+using Robust.Shared.Utility;
+
+namespace Robust.UnitTesting.Shared.RichText;
+
+[Parallelizable(ParallelScope.All)]
+[TestFixture, TestOf(typeof(FormattedStringBuilder))]
+public static class FormattedStringBuilderTest
+{
+ [Test]
+ public static void TestPlainText()
+ {
+ var fsb = new FormattedStringBuilder();
+ fsb.AppendText("Foobar");
+ fsb.AppendLine();
+ fsb.AppendMarkup("Wawa");
+
+ AssertMarkup(fsb, "Foobar\nWawa");
+ }
+
+ [Test]
+ public static void TestPlainTextExistingBuilder()
+ {
+ var sb = new StringBuilder();
+ sb.Append("Guh");
+ var fsb = new FormattedStringBuilder(sb);
+ fsb.AppendText("Foobar");
+ fsb.AppendLine();
+ fsb.AppendMarkup("Wawa");
+
+ AssertMarkup(fsb, "GuhFoobar\nWawa");
+ }
+
+ [Test]
+ public static void TestBasicTag()
+ {
+ var fsb = new FormattedStringBuilder();
+ fsb.BeginTag("bold");
+ fsb.FinishTagOpen();
+
+ fsb.AppendText("bar");
+ fsb.PopTag();
+
+ AssertMarkup(fsb, "[bold]bar[/bold]");
+ }
+
+ [Test]
+ public static void TestBasicTagFormattedString()
+ {
+ var fsb = new FormattedStringBuilder();
+ fsb.BeginTag("bold");
+ fsb.FinishTagOpen();
+
+ fsb.AppendText("bar");
+ fsb.PopTag();
+
+ Assert.That((FormattedMessage)fsb.ToFormattedString(), Is.EqualTo(FormattedMessage.FromMarkupOrThrow("[bold]bar[/bold]")));
+ }
+
+ [Test]
+ public static void TestSelfClosingTag()
+ {
+ var fsb = new FormattedStringBuilder();
+ fsb.BeginTag("bold");
+ fsb.FinishTagSelfClosed();
+
+ AssertMarkup(fsb, "[bold /]");
+ }
+
+ [Test]
+ public static void TestTagValueLong()
+ {
+ var fsb = new FormattedStringBuilder();
+ fsb.BeginTag("bold", 10);
+ fsb.FinishTagSelfClosed();
+
+ AssertMarkup(fsb, "[bold=10 /]");
+ }
+
+ [Test]
+ public static void TestTagValueString()
+ {
+ var fsb = new FormattedStringBuilder();
+ fsb.BeginTag("bold", "wawa");
+ fsb.FinishTagSelfClosed();
+
+ AssertMarkup(fsb, "[bold=\"wawa\" /]");
+ }
+
+ [Test]
+ public static void TestTagValueColor()
+ {
+ var fsb = new FormattedStringBuilder();
+ fsb.BeginTag("bold", Color.FromHex("#AAA"));
+ fsb.FinishTagSelfClosed();
+
+ AssertMarkup(fsb, "[bold=#AAA /]");
+ }
+
+ [Test]
+ public static void TestTagAttributeString()
+ {
+ AssertMarkup(
+ Fsb().BeginTag("bold").TagAttribute("a", "b").FinishTagSelfClosed(),
+ "[bold a=\"b\" /]");
+ }
+
+ [Test]
+ public static void TestTagAttributeLong()
+ {
+ AssertMarkup(
+ Fsb().BeginTag("bold").TagAttribute("a", 10).FinishTagSelfClosed(),
+ "[bold a=10 /]");
+ }
+
+ [Test]
+ public static void TestTagAttributeColor()
+ {
+ AssertMarkup(
+ Fsb().BeginTag("bold").TagAttribute("a", Color.FromHex("#AAA")).FinishTagSelfClosed(),
+ "[bold a=#AAA /]");
+ }
+
+ [Test]
+ public static void TestAppendMarkup()
+ {
+ AssertMarkup(
+ Fsb().AppendMarkup("[bold /]"),
+ "[bold /]");
+ }
+
+ [Test]
+ public static void TestAppendMarkupLine()
+ {
+ AssertMarkup(
+ Fsb().AppendMarkupLine("[bold /]"),
+ "[bold /]\n");
+ }
+
+ [Test]
+ public static void TestBeginInvalid()
+ {
+ var fsb = Fsb().BeginTag("a");
+
+ Assert.That(() => fsb.BeginTag("a"), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestBeginValueInvalid()
+ {
+ var fsb = Fsb().BeginTag("a");
+
+ Assert.That(() => fsb.BeginTag("a", "b"), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestTagAttributeInvalid()
+ {
+ var fsb = Fsb();
+
+ Assert.That(() => fsb.TagAttribute("a", "b"), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestFinishTagSelfClosedInvalid()
+ {
+ var fsb = Fsb();
+
+ Assert.That(() => fsb.FinishTagSelfClosed(), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestFinishTagOpenInvalid()
+ {
+ var fsb = Fsb();
+
+ Assert.That(() => fsb.FinishTagOpen(), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestPopTagEmpty()
+ {
+ var fsb = Fsb();
+
+ Assert.That(() => fsb.PopTag(), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestPopTagInvalid()
+ {
+ var fsb = Fsb().BeginTag("a");
+
+ Assert.That(() => fsb.PopTag(), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestAppendTextInvalid()
+ {
+ var fsb = Fsb().BeginTag("a");
+
+ Assert.That(() => fsb.AppendText("A"), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestAppendMarkupInvalid()
+ {
+ var fsb = Fsb().BeginTag("a");
+
+ Assert.That(() => fsb.AppendMarkup("A"), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestAppendMarkupInvalidMarkup()
+ {
+ var fsb = Fsb();
+
+ Assert.That(() => fsb.AppendMarkup("[wawa"), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestAppendLineInvalid()
+ {
+ var fsb = Fsb().BeginTag("a");
+
+ Assert.That(() => fsb.AppendLine(), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestAppendMarkupLineInvalid()
+ {
+ var fsb = Fsb().BeginTag("a");
+
+ Assert.That(() => fsb.AppendMarkupLine("guh"), Throws.TypeOf());
+ }
+
+ [Test]
+ public static void TestAppendMarkupLineInvalidMarkup()
+ {
+ var fsb = Fsb();
+
+ Assert.That(() => fsb.AppendMarkupLine("[guh"), Throws.TypeOf());
+ }
+
+ private static void AssertMarkup(FormattedStringBuilder fsb, string expected)
+ {
+ Assert.That(
+ FormattedMessage.FromMarkupOrThrow(fsb.ToString()),
+ Is.EqualTo(FormattedMessage.FromMarkupOrThrow(expected)));
+ }
+
+ private static FormattedStringBuilder Fsb() => new FormattedStringBuilder();
+}
diff --git a/Robust.Shared.Tests/Utility/CommandParsing_Test.cs b/Robust.Shared.Tests/Utility/CommandParsing_Test.cs
index b99089f2bbe..3a9d7c213ac 100644
--- a/Robust.Shared.Tests/Utility/CommandParsing_Test.cs
+++ b/Robust.Shared.Tests/Utility/CommandParsing_Test.cs
@@ -33,5 +33,22 @@ public void TestEscape(string source, string expected)
Assert.That(escaped, Is.EqualTo(expected));
}
+
+ [TestCase("foo;bar")]
+ [TestCase("\"foo;bar")]
+ [TestCase("f oo;bar")]
+ [TestCase("f\\ oo;bar")]
+ public void TestEscapeCommand(string source)
+ {
+ var args = source.Split(';');
+
+ var result = new List();
+ var escapedCommand = CommandParsing.EscapeCommand(args);
+ TestContext.Out.WriteLine($"Escaped command: {escapedCommand}");
+
+ CommandParsing.ParseArguments(escapedCommand, result);
+
+ Assert.That(result, Is.EquivalentTo(args));
+ }
}
}
diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs
index fa1608e43ba..d4c67e3998d 100644
--- a/Robust.Shared/CVars.cs
+++ b/Robust.Shared/CVars.cs
@@ -996,6 +996,18 @@ protected CVars()
public static readonly CVarDef AuthServer =
CVarDef.Create("auth.server", AuthManager.DefaultAuthServer, CVar.SERVERONLY);
+ ///
+ /// Trust score for unauthenticated localhost connections
+ ///
+ public static readonly CVarDef AuthLocalTrust =
+ CVarDef.Create("auth.localtrust", 1f, CVar.SERVERONLY);
+
+ ///
+ /// Trust score for guest connections
+ ///
+ public static readonly CVarDef AuthGuestTrust =
+ CVarDef.Create("auth.guesttrust", 0f, CVar.SERVERONLY);
+
/*
* RENDERING
*/
diff --git a/Robust.Shared/Console/Commands/ListCommand.cs b/Robust.Shared/Console/Commands/ListCommand.cs
index 53df9233f26..6b9c7f5ded4 100644
--- a/Robust.Shared/Console/Commands/ListCommand.cs
+++ b/Robust.Shared/Console/Commands/ListCommand.cs
@@ -18,7 +18,7 @@ public override void Execute(IConsoleShell shell, string argStr, string[] args)
var builder = new StringBuilder(Loc.GetString("cmd-list-heading"));
foreach (var command in host.AvailableCommands.Values
- .Where(p => p.Command.Contains(filter))
+ .Where(p => p.Command.Contains(filter) && !host.IsCommandHidden(p))
.OrderBy(c => c.Command))
{
//TODO: Make this actually check permissions.
diff --git a/Robust.Shared/Console/ConsoleHost.cs b/Robust.Shared/Console/ConsoleHost.cs
index 5b6c239ec1a..58fd66fa270 100644
--- a/Robust.Shared/Console/ConsoleHost.cs
+++ b/Robust.Shared/Console/ConsoleHost.cs
@@ -81,6 +81,11 @@ public void LoadConsoleCommands()
}
}
+ public bool IsCommandHidden(IConsoleCommand command)
+ {
+ return command.Command.StartsWith('_');
+ }
+
protected virtual void UpdateAvailableCommands()
{
}
diff --git a/Robust.Shared/Console/IConsoleCommand.cs b/Robust.Shared/Console/IConsoleCommand.cs
index b59d220729e..9f6544d25d7 100644
--- a/Robust.Shared/Console/IConsoleCommand.cs
+++ b/Robust.Shared/Console/IConsoleCommand.cs
@@ -21,6 +21,9 @@ public interface IConsoleCommand
///
/// A string as identifier for this command.
///
+ ///
+ /// Commands starting with '_' are treated as "hidden". They will not be shown in listings or completions.
+ ///
string Command { get; }
///
diff --git a/Robust.Shared/Console/IConsoleHost.cs b/Robust.Shared/Console/IConsoleHost.cs
index 66b86419260..6ee2f4ea897 100644
--- a/Robust.Shared/Console/IConsoleHost.cs
+++ b/Robust.Shared/Console/IConsoleHost.cs
@@ -66,6 +66,12 @@ public interface IConsoleHost
///
void LoadConsoleCommands();
+ ///
+ /// Check whether a console command is hidden.
+ ///
+ ///
+ bool IsCommandHidden(IConsoleCommand command);
+
#region RegisterCommand
///
/// Registers a console command into the console system. This is an alternative to
diff --git a/Robust.Shared/GameObjects/Systems/EntityLookup.Queries.cs b/Robust.Shared/GameObjects/Systems/EntityLookup.Queries.cs
index 7d7d2ead228..9eee6955726 100644
--- a/Robust.Shared/GameObjects/Systems/EntityLookup.Queries.cs
+++ b/Robust.Shared/GameObjects/Systems/EntityLookup.Queries.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Diagnostics.Contracts;
using System.Numerics;
using System.Runtime.CompilerServices;
using Robust.Shared.Collections;
@@ -803,18 +804,21 @@ public void FindLookupsIntersecting(MapId mapId, Box2Rotated worldBounds, Compon
#region Bounds
+ [Pure]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Box2 GetLocalBounds(Vector2i gridIndices, ushort tileSize)
{
return new Box2(gridIndices * tileSize, (gridIndices + 1) * tileSize);
}
+ [Pure]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Box2 GetLocalBounds(TileRef tileRef, ushort tileSize)
{
return GetLocalBounds(tileRef.GridIndices, tileSize);
}
+ [Pure]
public Box2Rotated GetWorldBounds(TileRef tileRef, Matrix3x2? worldMatrix = null, Angle? angle = null)
{
var grid = _gridQuery.GetComponent(tileRef.GridUid);
diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.cs
index 6c21c94534d..5e4709ffc36 100644
--- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.cs
+++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
@@ -792,6 +793,18 @@ public IEnumerable GetAllTiles(EntityUid uid, MapGridComponent grid, bo
}
}
+ ///
+ /// Returns the total number of tiles on a grid.
+ /// by summing the counts of filled tiles in each chunk.
+ ///
+ /// The target map grid entity
+ /// The total number of filled tiles in .
+ [Pure]
+ public int GetFilledTileCount(Entity ent)
+ {
+ return ent.Comp.Chunks.Values.Sum(chunk => chunk.FilledTiles);
+ }
+
public GridTileEnumerator GetAllTilesEnumerator(EntityUid uid, MapGridComponent grid, bool ignoreEmpty = true)
{
return new GridTileEnumerator(uid, grid.Chunks.GetEnumerator(), grid.ChunkSize, ignoreEmpty);
diff --git a/Robust.Shared/Localization/LocalizationManager.Functions.cs b/Robust.Shared/Localization/LocalizationManager.Functions.cs
index 0704f2b346f..354db76939f 100644
--- a/Robust.Shared/Localization/LocalizationManager.Functions.cs
+++ b/Robust.Shared/Localization/LocalizationManager.Functions.cs
@@ -9,6 +9,7 @@
using Robust.Shared.GameObjects;
using Robust.Shared.GameObjects.Components.Localization;
using Robust.Shared.Maths;
+using Robust.Shared.Utility;
namespace Robust.Shared.Localization
{
@@ -42,6 +43,10 @@ private void AddBuiltInFunctions(FluentBundle bundle)
AddCtxFunction(bundle, "ATTRIB", args => FuncAttrib(bundle, args));
AddCtxFunction(bundle, "CAPITALIZE", FuncCapitalize);
AddCtxFunction(bundle, "INDEFINITE", FuncIndefinite);
+
+ // Rich text
+ AddCtxFunction(bundle, "ESCAPE", FuncEscape);
+ AddCtxFunction(bundle, "ESCAPE-PARAM", FuncEscapeParam);
}
///
@@ -217,6 +222,7 @@ private ILocValue FuncDatObj(LocArgs args)
/// Returns the respective genitive form (pronoun or possessive adjective) for the entity's gender.
/// This is used in languages with a genitive case to indicate possession or related relationships,
/// e.g., "у него" (Russian), "seines Vaters" (German).
+ ///
private ILocValue FuncGenitive(LocArgs args)
{
return new LocValueString(GetString("zzzz-genitive", ("ent", args.Args[0])));
@@ -369,6 +375,24 @@ public void AddFunction(CultureInfo culture, string name, LocFunction function)
bundle.AddFunctionOverriding(name, (args, options)
=> CallFunction(function, bundle, args, options));
}
+
+ ///
+ /// Escape the provided string argument for insertion among rich text markup.
+ ///
+ private static ILocValue FuncEscape(LocArgs args)
+ {
+ var input = args.Args[0].Format(new LocContext());
+ return new LocValueString(FormattedMessage.EscapeText(input));
+ }
+
+ ///
+ /// Escape the provided string argument for insertion as a string markup parameter.
+ ///
+ private static ILocValue FuncEscapeParam(LocArgs args)
+ {
+ var input = args.Args[0].Format(new LocContext());
+ return new LocValueString(FormattedMessage.EscapeStringParameter(input));
+ }
}
internal sealed class FluentLocWrapperType : IFluentType
diff --git a/Robust.Shared/Network/NetManager.ServerAuth.cs b/Robust.Shared/Network/NetManager.ServerAuth.cs
index f11458d669c..7f1303d4b74 100644
--- a/Robust.Shared/Network/NetManager.ServerAuth.cs
+++ b/Robust.Shared/Network/NetManager.ServerAuth.cs
@@ -179,7 +179,8 @@ private async void HandleHandshake(NetPeerData peer, NetConnection connection)
HWId = legacyHwid,
ModernHWIds = modernHWIds,
Trust = joinedRespJson.ConnectionData!.Trust,
- CreatedTime = joinedRespJson.UserData.CreatedTime
+ CreatedTime = joinedRespJson.UserData.CreatedTime,
+ IsLocal = isLocal
};
padSuccessMessage = false;
type = LoginType.LoggedIn;
@@ -220,10 +221,15 @@ private async void HandleHandshake(NetPeerData peer, NetConnection connection)
_logger.Verbose(
$"{connection.RemoteEndPoint}: Assigned user ID: {userId}");
+ var localTrust = _config.GetCVar(CVars.AuthLocalTrust);
+ var guestTrust = _config.GetCVar(CVars.AuthGuestTrust);
+
userData = new NetUserData(userId, name)
{
HWId = [],
- ModernHWIds = []
+ ModernHWIds = [],
+ Trust = isLocal ? localTrust : guestTrust,
+ IsLocal = isLocal
};
}
diff --git a/Robust.Shared/Network/NetUserData.cs b/Robust.Shared/Network/NetUserData.cs
index 7ee61388e9a..1aa7aa25a87 100644
--- a/Robust.Shared/Network/NetUserData.cs
+++ b/Robust.Shared/Network/NetUserData.cs
@@ -41,6 +41,11 @@ public sealed record NetUserData
///
public float Trust { get; init; }
+ ///
+ /// True if the player is connecting from a local address.
+ ///
+ public bool IsLocal { get; init; }
+
public NetUserData(NetUserId userId, string userName)
{
UserId = userId;
diff --git a/Robust.Shared/RichText/FormattedStringBuilder.cs b/Robust.Shared/RichText/FormattedStringBuilder.cs
new file mode 100644
index 00000000000..0f059dd8b26
--- /dev/null
+++ b/Robust.Shared/RichText/FormattedStringBuilder.cs
@@ -0,0 +1,367 @@
+using System;
+using System.Text;
+using Robust.Shared.Collections;
+using Robust.Shared.Maths;
+using Robust.Shared.Utility;
+
+namespace Robust.Shared.RichText;
+
+///
+/// A wrapper around , with convenience methods for safely constructing rich text markup.
+///
+///
+///
+/// Tags are written with multiple consecutive calls. Functions may throw if not in the right state,
+/// and this can be checked with .
+/// It should go without saying that care must be taken to use the underlying
+/// while this is the case.
+///
+///
+/// While the underlying is accessible, you are of course responsible for writing valid
+/// markup and escaping if necessary.
+///
+///
+public sealed class FormattedStringBuilder
+{
+ private ValueList _tagStack;
+
+ ///
+ /// The underlying used by this instance.
+ ///
+ ///
+ /// you are responsible for writing valid markup and escaping where necessary, if you access this property.
+ ///
+ public StringBuilder Builder { get; }
+
+ ///
+ /// If true, we are currently writing a tag.
+ ///
+ ///
+ /// This can be ended through or .
+ ///
+ public bool IsInsideTag { get; private set; } = true;
+
+ ///
+ /// Create a new builder with an empty underlying .
+ ///
+ public FormattedStringBuilder() : this(new StringBuilder())
+ {
+
+ }
+
+ ///
+ /// Create a new builder wrapping an existing .
+ ///
+ ///
+ /// The provided instance is not initially mutated.
+ ///
+ public FormattedStringBuilder(StringBuilder builder)
+ {
+ Builder = builder;
+ }
+
+ ///
+ /// Begin a new tag with the specified name.
+ ///
+ /// The name of the tag to begin.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're already inside a tag.
+ ///
+ public FormattedStringBuilder BeginTag(string tagName)
+ {
+ if (!IsInsideTag)
+ throw new InvalidOperationException("Cannot begin tag: we're already in a tag");
+
+ _tagStack.Push(tagName);
+ IsInsideTag = false;
+ Builder.Append($"[{tagName}");
+
+ return this;
+ }
+
+ ///
+ /// Begin a new tag with the specified name and a value.
+ ///
+ /// The name of the tag to begin.
+ /// The value of the markup tag.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're already inside a tag.
+ ///
+ public FormattedStringBuilder BeginTag(string tagName, MarkupParameter value)
+ {
+ BeginTag(tagName);
+
+ Builder.Append(value.ToString());
+
+ return this;
+ }
+
+ ///
+ /// Begin a new tag with the specified name and a value.
+ ///
+ /// The name of the tag to begin.
+ /// The value of the markup tag.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're already inside a tag.
+ ///
+ public FormattedStringBuilder BeginTag(string tagName, string value)
+ {
+ return BeginTag(tagName, new MarkupParameter(value));
+ }
+
+ ///
+ /// Begin a new tag with the specified name and a value.
+ ///
+ /// The name of the tag to begin.
+ /// The value of the markup tag.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're already inside a tag.
+ ///
+ public FormattedStringBuilder BeginTag(string tagName, long value)
+ {
+ return BeginTag(tagName, new MarkupParameter(value));
+ }
+
+ ///
+ /// Begin a new tag with the specified name and a value.
+ ///
+ /// The name of the tag to begin.
+ /// The value of the markup tag.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're already inside a tag.
+ ///
+ public FormattedStringBuilder BeginTag(string tagName, Color value)
+ {
+ return BeginTag(tagName, new MarkupParameter(value));
+ }
+
+ ///
+ /// Specify an attribute for the tag currently being written.
+ ///
+ ///
+ /// This does not check for duplicates.
+ ///
+ /// The name of the attribute to write.
+ /// The value of the attribute.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're not currently inside a tag.
+ ///
+ public FormattedStringBuilder TagAttribute(string attributeName, MarkupParameter value)
+ {
+ if (IsInsideTag)
+ throw new InvalidOperationException("Cannot write attribute: we aren't in a tag!");
+
+ Builder.Append($" {attributeName}{value}");
+ return this;
+ }
+
+ ///
+ /// Specify an attribute for the tag currently being written.
+ ///
+ ///
+ /// This does not check for duplicates.
+ ///
+ /// The name of the attribute to write.
+ /// The value of the attribute.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're not currently inside a tag.
+ ///
+ public FormattedStringBuilder TagAttribute(string attributeName, string value)
+ {
+ return TagAttribute(attributeName, new MarkupParameter(value));
+ }
+
+ ///
+ /// Specify an attribute for the tag currently being written.
+ ///
+ ///
+ /// This does not check for duplicates.
+ ///
+ /// The name of the attribute to write.
+ /// The value of the attribute.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're not currently inside a tag.
+ ///
+ public FormattedStringBuilder TagAttribute(string attributeName, long value)
+ {
+ return TagAttribute(attributeName, new MarkupParameter(value));
+ }
+
+ ///
+ /// Specify an attribute for the tag currently being written.
+ ///
+ ///
+ /// This does not check for duplicates.
+ ///
+ /// The name of the attribute to write.
+ /// The value of the attribute.
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're not currently inside a tag.
+ ///
+ public FormattedStringBuilder TagAttribute(string attributeName, Color value)
+ {
+ return TagAttribute(attributeName, new MarkupParameter(value));
+ }
+
+ ///
+ /// Finish writing the current tag as self-closed.
+ ///
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're not currently inside a tag.
+ ///
+ public FormattedStringBuilder FinishTagSelfClosed()
+ {
+ if (IsInsideTag)
+ throw new InvalidOperationException("Cannot finish tag: we aren't in a tag!");
+
+ _tagStack.Pop();
+ Builder.Append("/]");
+ IsInsideTag = true;
+ return this;
+ }
+
+ ///
+ /// Finish writing the current tag as open. You will have to close it with .
+ ///
+ /// The current instance, to enable easy method call chaining.
+ ///
+ /// Thrown if we're not currently inside a tag.
+ ///
+ public FormattedStringBuilder FinishTagOpen()
+ {
+ if (IsInsideTag)
+ throw new InvalidOperationException("Cannot finish tag: we aren't in a tag!");
+
+ Builder.Append(']');
+ IsInsideTag = true;
+ return this;
+ }
+
+ ///
+ /// Write a closing tag for the most recent open tag.
+ ///
+ ///
+ /// The stack of open tags (from ) is automatically tracked.
+ ///
+ /// The current instance, to enable easy method call chaining.
+ ///
+ public FormattedStringBuilder PopTag()
+ {
+ if (!IsInsideTag)
+ throw new InvalidOperationException("Cannot begin tag: we're already in a tag");
+
+ Builder.Append($"[/{_tagStack.Pop()}]");
+ return this;
+ }
+
+ ///
+ /// Append plain text.
+ ///
+ /// The text to append without interpreting formatting.
+ ///
+ /// Thrown if we're currently inside a tag.
+ ///
+ /// The current instance, to enable easy method call chaining.
+ public FormattedStringBuilder AppendText(string text)
+ {
+ CheckSafe();
+ Builder.Append(FormattedMessage.EscapeText(text));
+ return this;
+ }
+
+ ///
+ /// Append markup.
+ ///
+ /// The text to append as markup.
+ ///
+ /// Thrown if is not valid markup.
+ ///
+ ///
+ /// Thrown if we're currently inside a tag.
+ ///
+ /// The current instance, to enable easy method call chaining.
+ public FormattedStringBuilder AppendMarkup(string markup)
+ {
+ CheckSafe();
+
+ if (!FormattedMessage.ValidMarkup(markup))
+ throw new ArgumentException("Not valid markup!", nameof(markup));
+
+ Builder.Append(markup);
+ return this;
+ }
+
+ ///
+ /// Append markup, followed by a newline.
+ ///
+ ///
+ /// The added line is always a single Line Feed (LF), not .
+ ///
+ /// The text to append as markup.
+ ///
+ /// Thrown if is not valid markup.
+ ///
+ ///
+ /// Thrown if we're currently inside a tag.
+ ///
+ /// The current instance, to enable easy method call chaining.
+ public FormattedStringBuilder AppendMarkupLine(string markup)
+ {
+ AppendMarkup(markup);
+ AppendLine();
+ return this;
+ }
+
+ ///
+ /// Append a newline.
+ ///
+ ///
+ /// The added line is always a single Line Feed (LF), not .
+ ///
+ ///
+ /// Thrown if we're currently inside a tag.
+ ///
+ /// The current instance, to enable easy method call chaining.
+ public FormattedStringBuilder AppendLine()
+ {
+ CheckSafe();
+ Builder.Append('\n');
+ return this;
+ }
+
+ ///
+ /// Returns the internal value as a string.
+ ///
+ public override string ToString()
+ {
+ return Builder.ToString();
+ }
+
+ ///
+ /// Returns the internal value as a .
+ ///
+ ///
+ /// Thrown if the contained string is not valid markup
+ /// (e.g. if you manually messed with the underlying .)
+ ///
+ public FormattedString ToFormattedString()
+ {
+ return FormattedString.FromMarkup(ToString());
+ }
+
+ private void CheckSafe()
+ {
+ if (!IsInsideTag)
+ throw new InvalidOperationException("Cannot append: we are currently writing a tag.");
+ }
+}
diff --git a/Robust.Shared/RichText/FormattedStringBuilderExtensions.cs b/Robust.Shared/RichText/FormattedStringBuilderExtensions.cs
new file mode 100644
index 00000000000..6449f9c054f
--- /dev/null
+++ b/Robust.Shared/RichText/FormattedStringBuilderExtensions.cs
@@ -0,0 +1,28 @@
+namespace Robust.Shared.RichText;
+
+///
+/// Extension methods for .
+///
+public static class FormattedStringBuilderExtensions
+{
+ extension(FormattedStringBuilder builder)
+ {
+ ///
+ /// Write a cmdlink tag.
+ ///
+ /// The user-visible tag for the link.
+ /// The command executed when the user clicks.
+ /// The tooltip (title) when the user hovers over the link.
+ /// The current instance, to enable easy method call chaining.
+ public FormattedStringBuilder MakeCommandLinkTag(string text, string command, string? title = null)
+ {
+ builder.BeginTag("cmdlink", text);
+ builder.TagAttribute("command", command);
+ if (title != null)
+ builder.TagAttribute("title", title);
+ builder.FinishTagSelfClosed();
+
+ return builder;
+ }
+ }
+}
diff --git a/Robust.Shared/Utility/CommandParsing.cs b/Robust.Shared/Utility/CommandParsing.cs
index a6e5e16a92c..1fd48dd5baf 100644
--- a/Robust.Shared/Utility/CommandParsing.cs
+++ b/Robust.Shared/Utility/CommandParsing.cs
@@ -1,4 +1,5 @@
using System;
+using System.Buffers;
using System.Collections.Generic;
using System.Text;
using Robust.Shared.Collections;
@@ -7,6 +8,8 @@ namespace Robust.Shared.Utility
{
public static class CommandParsing
{
+ private static readonly SearchValues CommandArgumentSeparator = SearchValues.Create(" ");
+
///
/// Parses a full console command into a list of arguments.
///
@@ -63,7 +66,7 @@ internal static void ParseArguments(
continue;
}
- if (chr == ' ' && !inQuotes)
+ if (CommandArgumentSeparator.Contains(chr) && !inQuotes)
{
if (sb.Length != 0)
{
@@ -93,5 +96,38 @@ public static string Escape(string text)
{
return text.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
+
+ ///
+ /// Split a set of arguments into a string that can be parsed round-trip.
+ ///
+ ///
+ ///
+ /// This is effectively the inverse of .
+ ///
+ ///
+ public static string EscapeCommand(params string[] arguments)
+ {
+ var sb = new StringBuilder();
+
+ var first = true;
+
+ foreach (var entry in arguments)
+ {
+ if (!first)
+ sb.Append(' ');
+ first = false;
+
+ var quoted = entry.ContainsAny(CommandArgumentSeparator);
+ if (quoted)
+ sb.Append('"');
+
+ sb.Append(Escape(entry));
+
+ if (quoted)
+ sb.Append('"');
+ }
+
+ return sb.ToString();
+ }
}
}
diff --git a/XamlX b/XamlX
index dca5a5f8c27..5da4e1d570a 160000
--- a/XamlX
+++ b/XamlX
@@ -1 +1 @@
-Subproject commit dca5a5f8c2759b940a87449584724ec71aa0dd19
+Subproject commit 5da4e1d570a13ee270fafccd3778d29fc6ddc7f2
diff --git a/cefglue b/cefglue
index 6b4dcf18337..f8f5135dbcb 160000
--- a/cefglue
+++ b/cefglue
@@ -1 +1 @@
-Subproject commit 6b4dcf1833739725ddfcd0c2b17624b04d447acd
+Subproject commit f8f5135dbcb6d1c1638043966d7a7c620676b5f8