diff --git a/command-and-clanker/OpenRA.Mods.Clanker/Traits/ClankerBridge.cs b/command-and-clanker/OpenRA.Mods.Clanker/Traits/ClankerBridge.cs index 5258ab9..5e71947 100644 --- a/command-and-clanker/OpenRA.Mods.Clanker/Traits/ClankerBridge.cs +++ b/command-and-clanker/OpenRA.Mods.Clanker/Traits/ClankerBridge.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -127,6 +128,22 @@ public class ClankerBridge : IWorldLoaded, ITick, INotifyActorDisposing, IRender // The id of the terminal currently mirrored, or null when none is selected. public string StreamedTerminal => streamedId; + // Sentinel BoundTerminal value: the panel targets every terminal at once + // (composer input broadcasts; no single grid is streamed). + public const string AllTerminals = "*"; + + string boundId; + + // What the terminal panel currently targets: a concrete terminal id, the + // AllTerminals sentinel, or null/empty when the panel is dismissed. Drives + // the streamed grid and where the composer sends. + public string BoundTerminal => boundId; + + // A stable, natural-sorted snapshot of the live terminal ids (t1, t2, ..., + // t10), read on the game thread; the panel's toggle cycles through these. + public IReadOnlyList TerminalIds => + islands.Keys.OrderBy(k => k.Length).ThenBy(k => k, StringComparer.Ordinal).ToList(); + World world; Player owner; MapCoords coords; @@ -934,6 +951,41 @@ public void SetStreamedTerminal(string id) Task.Run(() => RunTermStream(url, token)); } + // Point the panel at a terminal id, the AllTerminals sentinel, or null to + // dismiss it. A concrete id streams that terminal's grid; All and null stop + // the grid stream (the widget paints a placeholder / the panel hides). + public void SetBoundTerminal(string id) + { + boundId = id; + SetStreamedTerminal(string.IsNullOrEmpty(id) || id == AllTerminals ? null : id); + } + + // Advance the panel through the live terminals and then the All view, + // wrapping around; a dismissed or unknown binding cycles to the first one. + public void CycleBoundTerminal() + { + var options = new List(TerminalIds) { AllTerminals }; + var next = (options.IndexOf(boundId) + 1) % options.Count; + SetBoundTerminal(options[next]); + } + + // Send a composed line to the bound agent(s) via the backend's /agent/ask + // endpoint (which appends the submitting carriage return). In All mode the + // line is broadcast to every terminal. + public void SendComposed(string text) + { + if (string.IsNullOrEmpty(text)) + return; + + if (boundId == AllTerminals) + { + foreach (var id in TerminalIds) + ClankerBackend.Post("/agent/ask", new { terminal = id, text }); + } + else if (!string.IsNullOrEmpty(boundId)) + ClankerBackend.Post("/agent/ask", new { terminal = boundId, text }); + } + // Queue raw keystrokes for the selected terminal's PTY (e.g. "\r", "\x03"). public void SendTerminalInput(string data) { diff --git a/command-and-clanker/OpenRA.Mods.Clanker/Widgets/ClankerTerminalWidget.cs b/command-and-clanker/OpenRA.Mods.Clanker/Widgets/ClankerTerminalWidget.cs index 1807a4b..38651da 100644 --- a/command-and-clanker/OpenRA.Mods.Clanker/Widgets/ClankerTerminalWidget.cs +++ b/command-and-clanker/OpenRA.Mods.Clanker/Widgets/ClankerTerminalWidget.cs @@ -21,7 +21,6 @@ public class ClankerTerminalWidget : Widget public readonly Color Background = Color.FromArgb(235, 12, 14, 16); public readonly Color Foreground = Color.FromArgb(220, 220, 220); - readonly World world; readonly ClankerBridge bridge; int lastResizeCols; int lastResizeRows; @@ -29,26 +28,9 @@ public class ClankerTerminalWidget : Widget [ObjectCreator.UseCtor] public ClankerTerminalWidget(World world) { - this.world = world; bridge = world.WorldActor.TraitOrDefault(); } - // The selected terminal island's backend id, or null if none is selected. - string SelectedTerminal() - { - foreach (var a in world.Selection.Actors) - { - if (!a.IsInWorld) - continue; - - var r = a.TraitOrDefault(); - if (r != null) - return r.TerminalId; - } - - return null; - } - // Pixel size of one character cell, taken from the font's own metrics. (int W, int H) CellSize() { @@ -62,9 +44,9 @@ public override void Tick() if (bridge == null) return; - // With no separate sidebar, the panel follows the selection itself. - bridge.SetStreamedTerminal(SelectedTerminal()); - + // The panel binding is driven by ClankerTerminalLogic; here we only keep + // the streamed PTY sized to the grid. With no streamed terminal (the + // panel is dismissed or showing the All view) there is nothing to size. if (string.IsNullOrEmpty(bridge.StreamedTerminal)) { if (HasKeyboardFocus) @@ -92,7 +74,7 @@ public override void Tick() public override void Draw() { - if (string.IsNullOrEmpty(bridge?.StreamedTerminal)) + if (bridge == null) return; var font = Game.Renderer.Fonts[Font]; @@ -102,6 +84,15 @@ public override void Draw() var ox = rb.X + Padding; var oy = rb.Y + Padding; + // The All view has no single PTY to mirror; show what the composer targets. + if (bridge.BoundTerminal == ClankerBridge.AllTerminals) + { + font.DrawTextWithContrast( + $"All terminals — input broadcasts to {bridge.TerminalIds.Count} agent(s)", + new float2(ox, oy), Foreground, Color.Black, 1); + return; + } + var grid = bridge.TerminalGrid; if (grid == null) { diff --git a/command-and-clanker/OpenRA.Mods.Clanker/Widgets/Logic/ClankerTerminalLogic.cs b/command-and-clanker/OpenRA.Mods.Clanker/Widgets/Logic/ClankerTerminalLogic.cs new file mode 100644 index 0000000..547c3b7 --- /dev/null +++ b/command-and-clanker/OpenRA.Mods.Clanker/Widgets/Logic/ClankerTerminalLogic.cs @@ -0,0 +1,102 @@ +using OpenRA.Mods.Clanker.Traits; +using OpenRA.Mods.Common.Widgets; +using OpenRA.Widgets; + +namespace OpenRA.Mods.Clanker.Widgets.Logic +{ + // Wires the native-chrome controls around the terminal grid: a button that + // cycles which terminal the panel mirrors (t1 -> ... -> All), a composer that + // sends a typed line to the bound agent(s), and a close button that dismisses + // the panel. The grid itself is painted by ClankerTerminalWidget; this logic + // only drives the chrome and reconciles it with the map selection. + public class ClankerTerminalLogic : ChromeLogic + { + readonly World world; + readonly ClankerBridge bridge; + readonly Widget panel; + string lastSelection; + + [ObjectCreator.UseCtor] + public ClankerTerminalLogic(Widget widget, World world) + { + this.world = world; + bridge = world.WorldActor.TraitOrDefault(); + + panel = widget.Get("PANEL"); + + var mode = widget.Get("TERM_MODE"); + mode.GetText = () => + { + var bound = bridge?.BoundTerminal; + if (string.IsNullOrEmpty(bound)) + return "—"; + + return bound == ClankerBridge.AllTerminals ? "All" : bound; + }; + mode.OnClick = () => bridge?.CycleBoundTerminal(); + + var composer = widget.Get("TERM_COMPOSER"); + composer.OnEnterKey = _ => + { + if (composer.Text != "") + { + bridge?.SendComposed(composer.Text.Trim()); + composer.Text = ""; + } + + composer.YieldKeyboardFocus(); + return true; + }; + composer.OnEscKey = _ => + { + composer.YieldKeyboardFocus(); + return true; + }; + + var close = widget.Get("TERM_CLOSE"); + close.OnClick = () => + { + bridge?.SetBoundTerminal(null); + + // Clear the selection too, so the next Tick does not immediately + // re-bind the panel to the still-selected terminal island. + world.Selection.Clear(); + }; + } + + public override void Tick() + { + if (bridge == null) + return; + + // Selecting a terminal island on the map mirrors it in the panel. The + // mode button can then cycle away (including to All); a fresh selection + // re-binds. Acting only on change lets the button override map-follow. + var selected = SelectedTerminal(); + if (selected != lastSelection) + { + lastSelection = selected; + if (!string.IsNullOrEmpty(selected)) + bridge.SetBoundTerminal(selected); + } + + panel.Visible = !string.IsNullOrEmpty(bridge.BoundTerminal); + } + + // The backend id of the first selected terminal island, or null if none. + string SelectedTerminal() + { + foreach (var a in world.Selection.Actors) + { + if (!a.IsInWorld) + continue; + + var r = a.TraitOrDefault(); + if (r != null) + return r.TerminalId; + } + + return null; + } + } +} diff --git a/command-and-clanker/mods/clanker/chrome/clanker-terminal.yaml b/command-and-clanker/mods/clanker/chrome/clanker-terminal.yaml index 554e764..47cad87 100644 --- a/command-and-clanker/mods/clanker/chrome/clanker-terminal.yaml +++ b/command-and-clanker/mods/clanker/chrome/clanker-terminal.yaml @@ -1,8 +1,42 @@ -ClankerTerminal@CLANKER_TERMINAL: +Container@CLANKER_TERMINAL: + Logic: ClankerTerminalLogic X: 12 Y: WINDOW_HEIGHT - HEIGHT - 12 Width: 732 Height: 620 - Font: ClankerTerm - Padding: 6 - LineGap: 2 + Children: + Background@PANEL: + Width: PARENT_WIDTH + Height: PARENT_HEIGHT + Background: dialog + Children: + ClankerTerminal@GRID: + X: 9 + Y: 9 + Width: PARENT_WIDTH - 18 + Height: PARENT_HEIGHT - 52 + Font: ClankerTerm + Padding: 6 + LineGap: 2 + Button@TERM_MODE: + X: 9 + Y: PARENT_HEIGHT - 34 + Width: 50 + Height: 25 + Font: Bold + TextField@TERM_COMPOSER: + X: 64 + Y: PARENT_HEIGHT - 34 + Width: PARENT_WIDTH - 103 + Height: 25 + Button@TERM_CLOSE: + X: PARENT_WIDTH - 33 + Y: PARENT_HEIGHT - 34 + Width: 24 + Height: 25 + Children: + Image: + ImageCollection: lobby-bits + ImageName: kick + X: 6 + Y: 7