Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions command-and-clanker/OpenRA.Mods.Clanker/Traits/ClankerBridge.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string> TerminalIds =>
islands.Keys.OrderBy(k => k.Length).ThenBy(k => k, StringComparer.Ordinal).ToList();

World world;
Player owner;
MapCoords coords;
Expand Down Expand Up @@ -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<string>(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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,16 @@ 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;

[ObjectCreator.UseCtor]
public ClankerTerminalWidget(World world)
{
this.world = world;
bridge = world.WorldActor.TraitOrDefault<ClankerBridge>();
}

// 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<ClankerTerminalRef>();
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()
{
Expand All @@ -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)
Expand Down Expand Up @@ -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];
Expand All @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ClankerBridge>();

panel = widget.Get("PANEL");

var mode = widget.Get<ButtonWidget>("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<TextFieldWidget>("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<ButtonWidget>("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<ClankerTerminalRef>();
if (r != null)
return r.TerminalId;
}

return null;
}
}
}
42 changes: 38 additions & 4 deletions command-and-clanker/mods/clanker/chrome/clanker-terminal.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading