Editor UI panel for the Supershot tool that lets the user pick a saved screenshot, compose a Discord message, manage webhook channel configurations, and post images to Discord webhooks. It builds the sidebar, image picker, message composer, preview, and actions for testing/posting webhooks.
using System;
using System.Collections.Generic;
using System.IO;
using Sandbox;
namespace Editor.SuperShot;
public sealed class SharePanel : Widget
{
readonly SuperShotWindow _window;
readonly DiscordMessage _postMessage;
Widget _channels;
Label _previewMessage;
Label _targetSummary;
CapturedShot _selectedShot;
public SharePanel( SuperShotWindow window ) : base( null )
{
_window = window;
_postMessage = _window.Settings.Share.Message?.Clone() ?? new DiscordMessage();
Name = "Discord";
WindowTitle = "Discord";
SetWindowIcon( "forum" );
Layout = Layout.Column();
Layout.Margin = 8;
Layout.Spacing = 8;
_window.Changed += Rebuild;
Build();
}
void Build()
{
EnsureSelectedShot();
SuperShotUI.AddBanner( Layout, "Discord", "Pick a saved shot, write a simple message, and post it to Discord.", "forum" );
var workspace = new Widget( this );
workspace.Layout = Layout.Row();
workspace.Layout.Spacing = 8;
Layout.Add( workspace, 1 );
AddChannelSidebar( workspace.Layout );
AddComposerPane( workspace.Layout );
}
void Rebuild()
{
if ( !IsValid )
return;
Layout.Clear( true );
Build();
}
void AddComposerPane( Layout workspace )
{
var right = new ScrollArea( this );
right.FixedWidth = 540;
right.MinimumSize = new Vector2( 540, 0 );
right.MaximumSize = new Vector2( 540, 100000 );
right.Canvas = new Widget( right );
right.Canvas.FixedWidth = 520;
right.Canvas.Layout = Layout.Column();
right.Canvas.Layout.Margin = 0;
right.Canvas.Layout.Spacing = 8;
workspace.Add( right, 0 );
var body = right.Canvas.Layout;
AddPreviewCard( body );
AddImagePicker( body );
AddMessageCard( body );
body.AddStretchCell();
}
void AddChannelSidebar( Layout workspace )
{
var left = new ScrollArea( this );
left.Canvas = new Widget( left );
left.Canvas.Layout = Layout.Column();
left.Canvas.Layout.Margin = 0;
left.Canvas.Layout.Spacing = 8;
workspace.Add( left, 1 );
var body = left.Canvas.Layout;
AddPostActions( body );
AddChannels( body );
body.AddStretchCell();
}
void AddImagePicker( Layout body )
{
var card = SuperShotUI.AddCard( body, "What to Post", "image" );
var recentHeader = card.Body.AddRow();
recentHeader.Spacing = 4;
recentHeader.Add( new Label( string.IsNullOrEmpty( SelectedImagePath() ) ? "Pick a saved shot." : SelectedTitle() ) { Color = SuperShotUI.Muted } );
recentHeader.AddStretchCell();
recentHeader.Add( new Button( "Refresh", "refresh" ) { Clicked = () => _window.RefreshGalleryFromDisk() } );
if ( _window.Gallery.Count == 0 )
{
card.Body.Add( new Label( "No saved shots yet. Capture from the Home tab and they will show up here." ) { Color = SuperShotUI.Muted, WordWrap = true } );
return;
}
Layout row = null;
int inRow = 0;
int shown = 0;
for ( int i = _window.Gallery.Count - 1; i >= 0 && shown < 6; i--, shown++ )
{
if ( inRow == 0 )
{
row = card.Body.AddRow();
row.Spacing = 6;
}
var shot = _window.Gallery[i];
row.Add( new RecentShotButton( this, shot, shot == _selectedShot ), 1 );
inRow++;
if ( inRow == 2 )
{
inRow = 0;
row = null;
}
}
if ( row is not null )
row.AddStretchCell();
card.Body.Add( new Button( "Open Full Gallery", "collections" ) { Clicked = () => _window.DockManager.RaiseDock( "Gallery" ) } );
}
void AddMessageCard( Layout body )
{
var card = SuperShotUI.AddCard( body, "Message", "edit_note" );
card.Body.Add( new Label( "Write one simple message for this post. Leave it blank to send just the image." ) { Color = SuperShotUI.Muted, WordWrap = true } );
var so = _postMessage.GetSerialized();
so.OnPropertyChanged += _ => UpdatePreviewText();
card.Body.Add( SuperShotUI.SheetWidget( so, p => p.Name == nameof( DiscordMessage.Content ) ) );
var actions = card.Body.AddRow();
actions.Spacing = 4;
actions.Add( new Button( "Save as Default", "save" )
{
Clicked = () =>
{
_window.Settings.Share.Message = _postMessage.Clone();
_window.Settings.Save();
Log.Info( "[Supershot] Saved Discord message default." );
}
} );
actions.Add( new Button( "Clear", "backspace" )
{
Clicked = () =>
{
_postMessage.Content = "";
Rebuild();
}
} );
actions.AddStretchCell();
SuperShotUI.AddSection( card.Body, "Bot Identity (optional)", "smart_toy",
SuperShotUI.SheetWidget( so, p => p.Name is nameof( DiscordMessage.Username ) or nameof( DiscordMessage.AvatarUrl ) ),
"supershot.discord.identity", defaultOpen: false );
}
void AddPreviewCard( Layout body )
{
var card = SuperShotUI.AddCard( body, "Post Preview", "preview" );
_previewMessage = new Label( PreviewMessageText() ) { WordWrap = true };
card.Body.Add( _previewMessage );
card.Body.Add( new DiscordAttachmentPreview( _selectedShot ) );
_targetSummary = new Label( TargetSummaryText() ) { Color = SuperShotUI.Muted, WordWrap = true };
card.Body.Add( _targetSummary );
}
void AddChannels( Layout body )
{
var card = SuperShotUI.AddCard( body, "Discord Channels", "forum" );
card.Body.Add( new Label( "Add one webhook per Discord channel. Enable the channels that should receive Post to All." ) { Color = SuperShotUI.Muted, WordWrap = true } );
_channels = new Widget( null );
_channels.Layout = Layout.Column();
_channels.Layout.Spacing = 6;
card.Body.Add( _channels );
RebuildChannels();
if ( _window.Settings.Share.Webhooks.Count > 0 )
{
var addRow = card.Body.AddRow();
addRow.Spacing = 4;
addRow.Add( new Button.Primary( "Add Channel", "add" ) { Clicked = AddChannel } );
addRow.AddStretchCell();
}
}
void RebuildChannels()
{
_channels.Layout.Clear( true );
var list = _window.Settings.Share.Webhooks;
if ( list.Count == 0 )
{
var empty = new DiscordEmptyState( AddChannel );
_channels.Layout.Add( empty );
return;
}
foreach ( var webhook in list.ToArray() )
_channels.Layout.Add( BuildChannelCard( webhook ) );
}
Widget BuildChannelCard( DiscordWebhookConfig webhook )
{
var card = new Card();
var header = card.Body.AddRow();
header.Spacing = 6;
header.Add( new IconLabel( "tag" ) );
var title = new Label.Subtitle( string.IsNullOrWhiteSpace( webhook.Name ) ? "Unnamed channel" : webhook.Name );
header.Add( title );
header.Add( new StatusPill( webhook ) );
header.AddStretchCell();
var star = new Button( "Favorite", webhook.Favorite ? "star" : "star_outline" )
{
ToolTip = webhook.Favorite ? "Unpin from the quick-post menus" : "Pin to the quick-post menus",
Clicked = () =>
{
webhook.Favorite = !webhook.Favorite;
_window.Settings.Save();
RebuildChannels();
}
};
if ( webhook.Favorite )
star.Tint = new Color( 0.95f, 0.75f, 0.2f );
header.Add( star );
var so = webhook.GetSerialized();
so.OnPropertyChanged += _ => _window.Settings.Save();
card.Body.Add( SuperShotUI.SheetWidget( so, IsChannelBasic ) );
var urlHeader = card.Body.AddRow();
urlHeader.Spacing = 6;
urlHeader.Add( new IconLabel( "link" ) );
urlHeader.Add( new Label( "Webhook URL" ) { Color = SuperShotUI.Muted } );
urlHeader.AddStretchCell();
card.Body.Add( SuperShotUI.SheetWidget( so, p => p.Name == nameof( DiscordWebhookConfig.Url ) ) );
var actions = card.Body.AddRow();
actions.Spacing = 4;
actions.Add( new Button( "Test", "science" )
{
Enabled = webhook.IsConfigured,
ToolTip = "Send a tiny text-only test message to this channel.",
Clicked = () => _ = TestWebhook( webhook )
} );
actions.Add( new Button.Primary( "Post This Image", "send" )
{
Enabled = CanPostSelected() && webhook.IsConfigured,
ToolTip = "Post the selected library image to this channel.",
Clicked = () => _ = PostSelectedTo( webhook )
} );
actions.AddStretchCell();
actions.Add( new Button.Danger( "Remove", "delete" ) { Clicked = () => RemoveChannel( webhook ) } );
return card;
}
static bool IsChannelBasic( SerializedProperty p )
{
return p.Name is nameof( DiscordWebhookConfig.Name )
or nameof( DiscordWebhookConfig.Enabled );
}
void AddChannel()
{
_window.Settings.Share.Webhooks.Add( new DiscordWebhookConfig() );
_window.Settings.Save();
Rebuild();
}
void RemoveChannel( DiscordWebhookConfig webhook )
{
_window.Settings.Share.Webhooks.Remove( webhook );
_window.Settings.Save();
Rebuild();
}
void AddPostActions( Layout body )
{
bool canPost = CanPostSelected() && SuperShotService.EnabledWebhooks( _window.Settings.Share ).Count > 0;
var card = SuperShotUI.AddCard( body, "Post", "send" );
card.Body.Add( new Label( TargetSummaryText() ) { Color = SuperShotUI.Muted, WordWrap = true } );
var row = card.Body.AddRow();
row.Spacing = 4;
row.Add( new Button.Primary( "Post to All Enabled", "send" ) { Enabled = canPost, Clicked = () => _ = PostSelectedToMany( SuperShotService.EnabledWebhooks( _window.Settings.Share ) ) }, 1 );
row.Add( new Button( "Capture + Post to All", "photo_camera" ) { Enabled = SuperShotService.EnabledWebhooks( _window.Settings.Share ).Count > 0, Clicked = CaptureAndPostAll }, 1 );
}
void EnsureSelectedShot()
{
if ( _selectedShot is not null && _window.Gallery.Contains( _selectedShot ) )
return;
_selectedShot = FindShotByPath( _window.Settings.Share.LastSelectedImagePath );
if ( _selectedShot is not null )
return;
for ( int i = _window.Gallery.Count - 1; i >= 0; i-- )
{
if ( !string.IsNullOrEmpty( _window.Gallery[i].SavedPath ) )
{
_selectedShot = _window.Gallery[i];
return;
}
}
}
CapturedShot FindShotByPath( string path )
{
if ( string.IsNullOrEmpty( path ) )
return null;
foreach ( var shot in _window.Gallery )
{
if ( string.Equals( shot.SavedPath, path, StringComparison.OrdinalIgnoreCase ) )
return shot;
}
return null;
}
void SelectShot( CapturedShot shot )
{
_selectedShot = shot;
_window.Settings.Share.LastSelectedImagePath = shot?.SavedPath ?? "";
_window.Settings.Save();
Rebuild();
}
void CaptureAndPostAll()
{
var path = _window.Capture();
if ( string.IsNullOrEmpty( path ) )
return;
SelectShot( FindShotByPath( path ) ?? (_window.Gallery.Count > 0 ? _window.Gallery[^1] : null) );
_ = PostSelectedToMany( SuperShotService.EnabledWebhooks( _window.Settings.Share ) );
}
async System.Threading.Tasks.Task TestWebhook( DiscordWebhookConfig webhook )
{
var ok = await DiscordWebhook.PostText( webhook.Url, "Supershot test message. This Discord channel is connected." );
webhook.LastPostFailed = !ok;
webhook.LastError = ok ? "" : "Test failed";
_window.Settings.Save();
Rebuild();
}
async System.Threading.Tasks.Task PostSelectedTo( DiscordWebhookConfig webhook )
{
await SuperShotService.PostFileTo( webhook, SelectedImagePath(), _postMessage );
Rebuild();
}
async System.Threading.Tasks.Task PostSelectedToMany( IEnumerable<DiscordWebhookConfig> webhooks )
{
var list = new List<DiscordWebhookConfig>();
foreach ( var wh in webhooks )
{
if ( wh is not null && wh.Enabled && wh.IsConfigured )
list.Add( wh );
}
int sent = await SuperShotService.PostFileToMany( list, SelectedImagePath(), _postMessage );
Log.Info( $"[Supershot] Posted selected image to {sent}/{list.Count} Discord channel(s)." );
Rebuild();
}
string SelectedImagePath() => _selectedShot?.SavedPath;
bool CanPostSelected()
{
var path = SelectedImagePath();
return !string.IsNullOrEmpty( path ) && File.Exists( path );
}
string SelectedTitle()
{
var path = SelectedImagePath();
if ( string.IsNullOrEmpty( path ) )
return "No saved image selected";
return Path.GetFileName( path );
}
string SelectedDetails()
{
if ( _selectedShot is null )
return "Choose a recent shot from your library.";
var saved = string.IsNullOrEmpty( _selectedShot.SavedPath ) ? "Not saved yet" : _selectedShot.SavedPath;
return $"{_selectedShot.Size.x}x{_selectedShot.Size.y} · {_selectedShot.Time:g}\n{saved}";
}
string PreviewMessageText()
{
if ( string.IsNullOrWhiteSpace( _postMessage.Content ) )
return "";
return _postMessage.Content;
}
string TargetSummaryText()
{
int enabled = SuperShotService.EnabledWebhooks( _window.Settings.Share ).Count;
var image = CanPostSelected() ? Path.GetFileName( SelectedImagePath() ) : "no image selected";
return $"Posting {image} to {enabled} enabled channel(s).";
}
void UpdatePreviewText()
{
if ( _previewMessage is not null )
_previewMessage.Text = PreviewMessageText();
if ( _targetSummary is not null )
_targetSummary.Text = TargetSummaryText();
}
public override void OnDestroyed()
{
base.OnDestroyed();
_window.Changed -= Rebuild;
}
sealed class DiscordEmptyState : Widget
{
readonly Action _addChannel;
public DiscordEmptyState( Action addChannel ) : base( null )
{
_addChannel = addChannel;
MinimumSize = new Vector2( 0, 190 );
Layout = Layout.Column();
Layout.Margin = 18;
Layout.Spacing = 10;
Layout.Add( new Label.Subtitle( "No Discord channels yet" ) );
Layout.Add( new Label( "Create a Discord webhook in your server channel settings, then add it here. Supershot will save the shot locally and post it to the channels you enable." )
{
WordWrap = true,
Color = SuperShotUI.Muted
} );
var row = Layout.AddRow();
row.Spacing = 8;
row.Add( new Button.Primary( "Add Discord Channel", "add" )
{
FixedHeight = Theme.RowHeight * 2,
Clicked = _addChannel
} );
row.Add( new Button( "How to Get a Webhook", "help" )
{
FixedHeight = Theme.RowHeight * 2,
Clicked = () =>
{
EditorUtility.Clipboard.Copy( "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" );
Log.Info( "[Supershot] Copied Discord webhook setup guide URL." );
}
} );
row.AddStretchCell();
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( SuperShotUI.AccentDim.WithAlpha( 0.22f ) );
Paint.DrawRect( LocalRect, 8f );
Paint.ClearBrush();
Paint.SetPen( SuperShotUI.Accent.WithAlpha( 0.65f ), 1f );
Paint.DrawRect( LocalRect.Shrink( 0.5f ), 8f );
Paint.SetPen( SuperShotUI.Accent.WithAlpha( 0.9f ) );
Paint.DrawIcon( new Rect( Width - 68, 18, 44, 44 ), "forum", 34, TextFlag.Center );
}
}
sealed class SelectedShotPreview : Widget
{
readonly CapturedShot _shot;
public SelectedShotPreview( CapturedShot shot ) : base( null )
{
_shot = shot;
MinimumSize = new Vector2( 260, 170 );
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground.Darken( 0.25f ) );
Paint.DrawRect( LocalRect, 6f );
if ( _shot?.Thumbnail is not null )
{
Paint.Draw( FitRect( _shot.Thumbnail.Size, LocalRect.Shrink( 10 ) ), _shot.Thumbnail );
return;
}
Paint.SetPen( SuperShotUI.Muted );
Paint.DrawText( LocalRect, "No Image Selected", TextFlag.Center );
}
}
sealed class DiscordAttachmentPreview : Widget
{
readonly CapturedShot _shot;
public DiscordAttachmentPreview( CapturedShot shot ) : base( null )
{
_shot = shot;
MinimumSize = new Vector2( 0, 260 );
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( new Color( 0.09f, 0.10f, 0.12f ) );
Paint.DrawRect( LocalRect, 7f );
if ( _shot?.Thumbnail is not null )
{
var imageRect = FitRect( _shot.Thumbnail.Size, LocalRect.Shrink( 6 ) );
Paint.Draw( imageRect, _shot.Thumbnail );
}
else
{
Paint.SetPen( SuperShotUI.Muted );
Paint.DrawText( LocalRect, "No Image Selected", TextFlag.Center );
}
}
}
sealed class RecentShotButton : Widget
{
readonly SharePanel _panel;
readonly CapturedShot _shot;
readonly bool _selected;
public RecentShotButton( SharePanel panel, CapturedShot shot, bool selected ) : base( null )
{
_panel = panel;
_shot = shot;
_selected = selected;
MinimumSize = new Vector2( 0, 112 );
Cursor = CursorShape.Finger;
ToolTip = $"{shot.Size.x}x{shot.Size.y} {shot.Time:g}";
}
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( e.LeftMouseButton )
_panel.SelectShot( _shot );
}
protected override void OnPaint()
{
var bg = _selected
? SuperShotUI.AccentDim.WithAlpha( 0.7f )
: IsUnderMouse ? SuperShotUI.CardBorder.WithAlpha( 0.65f ) : SuperShotUI.CardBackground;
Paint.ClearPen();
Paint.SetBrush( bg );
Paint.DrawRect( LocalRect, 6f );
Paint.ClearBrush();
Paint.SetPen( _selected ? SuperShotUI.Accent : IsUnderMouse ? SuperShotUI.Accent : SuperShotUI.CardBorder, _selected ? 2f : 1f );
Paint.DrawRect( LocalRect.Shrink( 0.5f ), 6f );
var imageRect = new Rect( 6, 6, Width - 12, Height - 30 );
Paint.ClearPen();
Paint.SetBrush( Color.Black );
Paint.DrawRect( imageRect, 4f );
if ( _shot.Thumbnail is not null )
Paint.Draw( FitRect( _shot.Thumbnail.Size, imageRect ), _shot.Thumbnail );
else
{
Paint.SetPen( SuperShotUI.Muted );
Paint.DrawIcon( imageRect, "image", 22, TextFlag.Center );
}
Paint.SetDefaultFont( 7, 500 );
Paint.SetPen( Theme.Text.WithAlpha( 0.9f ) );
var label = _shot.SavedPath is not null ? "Saved shot" : "Recent shot";
Paint.DrawText( new Rect( 8, Height - 22, Width - 16, 10 ), label, TextFlag.LeftTop );
Paint.SetPen( SuperShotUI.Muted );
Paint.DrawText( new Rect( 8, Height - 12, Width - 16, 10 ), $"{_shot.Size.x}x{_shot.Size.y}", TextFlag.LeftTop );
}
}
sealed class StatusPill : Widget
{
readonly DiscordWebhookConfig _webhook;
public StatusPill( DiscordWebhookConfig webhook ) : base( null )
{
_webhook = webhook;
FixedSize = new Vector2( 112, 22 );
}
protected override void OnPaint()
{
var (text, color) = Status();
Paint.ClearPen();
Paint.SetBrush( color.WithAlpha( 0.22f ) );
Paint.DrawRect( LocalRect, 11f );
Paint.SetPen( color.WithAlpha( 0.9f ) );
Paint.DrawText( LocalRect, text, TextFlag.Center );
}
(string, Color) Status()
{
if ( _webhook.LastPostFailed )
return (string.IsNullOrWhiteSpace( _webhook.LastError ) ? "Failed" : _webhook.LastError, Color.Red);
if ( !_webhook.IsConfigured )
return ("No URL", Color.Orange);
if ( _webhook.LastPostedUtc is not null )
return ($"Posted {Ago( _webhook.LastPostedUtc.Value )}", Color.Green);
return ("Ready", SuperShotUI.Accent);
}
static string Ago( DateTime utc )
{
var span = DateTime.UtcNow - utc;
if ( span.TotalMinutes < 1 )
return "now";
if ( span.TotalHours < 1 )
return $"{(int)span.TotalMinutes}m ago";
if ( span.TotalDays < 1 )
return $"{(int)span.TotalHours}h ago";
return $"{(int)span.TotalDays}d ago";
}
}
static Rect FitRect( Vector2 content, Rect area )
{
if ( content.x <= 0 || content.y <= 0 || area.Width <= 0 || area.Height <= 0 )
return area;
float sx = area.Width / content.x;
float sy = area.Height / content.y;
float scale = sx < sy ? sx : sy;
var size = content * scale;
return new Rect( area.Left + (area.Width - size.x) * 0.5f, area.Top + (area.Height - size.y) * 0.5f, size.x, size.y );
}
}