Editor/EffigyEditor/EffigyMaterialsPanel.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Marionette.EditorTools;
/// <summary>
/// The Materials dock: the project's materials, in their folders, as a grid you drag out of.
///
/// WHAT THIS REPLACED. First a column of eight rows — "Slot 3 · material_3 (default) ·
/// [Browse...] · [×]" — which made you start from a NUMBER: to put brushed steel on something you
/// picked a slot you had no opinion about, opened a modal picker, found the material in there,
/// closed it, and then went and painted faces. Seven eighths of the dock was permanently a list of
/// names of things that did not exist yet, and the materials themselves — the only part with a
/// picture — were never on screen at all.
///
/// Then, briefly, EVERY MATERIAL IN A FLAT ALPHABETICAL GRID, which is why the folders below exist.
/// This project can see 1248 materials, 363 of them its own, and the overwhelming majority are
/// `materials/halo/characters/<something>/halo_0.vmat` through `halo_12.vmat` — hundreds of
/// cells whose names are all `halo_3`. The FOLDER is where the meaning is: `elite` tells you what
/// you are looking at and `halo_3` tells you nothing, so a listing that throws the folders away and
/// sorts the leaves alphabetically is strictly worse than no listing at all. It is also exactly what
/// the editor's own asset browser does not do, which is the whole point of the comparison.
///
/// So: navigate, do not enumerate. You land in `materials`, folders come first with a count each,
/// double-click to descend, the path bar walks you back out. Search is the escape hatch and searches
/// RECURSIVELY from wherever you are standing, the way the real browser's does.
///
/// WHAT THE SLOT DOES HERE. A face carries a slot number, not a material, and that has to stay true
/// — see FaceMaterialEdit. The slot is now shown rather than asked for: drag a material onto a face
/// and <see cref="Effigy.MaterialDrop"/> picks the slot, a material the document uses wears its slot
/// number as a badge in that slot's viewport tint, and the right-click menu is where you go when you
/// do want to bind a specific one by hand.
/// </summary>
internal sealed class EffigyMaterialsPanel : Widget, AssetSystem.IEventListener
{
private PartStudio _studio;
private readonly LineEdit _search;
private readonly Button _scopeButton;
private readonly EffigyPathBar _pathBar;
private readonly ListView _list;
private readonly Editor.Label _status;
/// <summary>Every material in the current scope, whatever folder it is in. The folder view is
/// computed from this on navigation rather than stored as a tree: there is one list, and where
/// you are standing is a string prefix into it.</summary>
private List<AssetEntry> _all = new();
/// <summary>Which slot each material is bound to, by the entry drawn for it. Rebuilt on
/// <see cref="Refresh"/> so <see cref="PaintCell"/> — which runs per cell per frame — reads a
/// dictionary rather than asking the kernel once per cell.</summary>
private readonly Dictionary<AssetEntry, int> _slots = new();
/// <summary>The same materials keyed by MaterialDrop.Normalise of their path, so a slot's name
/// finds its cell in one lookup.</summary>
private readonly Dictionary<string, AssetEntry> _byPath = new();
/// <summary>Project materials only, or everything the editor can see. Defaults to the project:
/// 363 against 1248 here, and the 885 are engine and mounted content you are not modelling
/// with.</summary>
private bool _projectOnly = true;
/// <summary>Where we are standing, as a path prefix with no trailing slash. Empty is the root.
/// </summary>
private string _folder = "";
/// <summary>Side of one cell. Big enough that two greys are different pictures rather than two
/// grey squares, which is the whole reason to show thumbnails instead of file names.</summary>
private const int CellSize = 92;
/// <summary>A slot was given a material, or cleared back to its numbered default. The same
/// contract this panel has always had, still wired to the window's SetSlotMaterial: the panel
/// reports the pick, the window owns the studio, the undo stack and the rebuild.</summary>
public Action<int, string> MaterialChanged { get; set; }
/// <summary>
/// A material was double-clicked.
///
/// The window binds this to the part's BASE material — slot 0, what every face nobody has
/// painted is on — which is the one assignment dragging cannot make: a drop lands on ONE face,
/// and MaterialDrop never allocates slot 0 precisely because doing so would paint the whole
/// part. Double-click is where "paint the whole part" belongs, so the two gestures cover the two
/// things you actually want and neither can be the other by accident.
/// </summary>
public Action<string> MaterialActivated { get; set; }
public EffigyMaterialsPanel( Widget parent, PartStudio studio ) : base( parent )
{
Name = "Materials";
WindowTitle = "Materials";
SetWindowIcon( "palette" );
_studio = studio;
Layout = Layout.Column();
var header = Layout.AddRow();
header.Margin = new Sandbox.UI.Margin( 6, 6, 6, 2 );
header.Spacing = 6;
_search = new LineEdit( this ) { PlaceholderText = "Search this folder" };
_search.TextEdited += _ => Populate();
header.Add( _search, 1 );
_scopeButton = new Button( "", "home", this ) { FixedWidth = 26 };
_scopeButton.Clicked = ToggleScope;
header.Add( _scopeButton );
var reload = new Button( "", "refresh", this ) { FixedWidth = 26, ToolTip = "Look for materials again" };
reload.Clicked = Rescan;
header.Add( reload );
_pathBar = new EffigyPathBar( this ) { Navigated = GoTo };
Layout.Add( _pathBar );
_list = Layout.Add( new ListView( this ), 1 );
_list.ItemSize = new Vector2( CellSize, CellSize + 22 );
_list.ItemSpacing = 2;
_list.MultiSelect = false;
_list.Margin = new Sandbox.UI.Margin( 4 );
_list.ItemPaint = PaintCell;
// Thumbnails are rendered on demand and cost real time, so the list only asks for the ones
// scrolled into view — the same deal the editor's own asset list makes. Without this, every
// material in the folder would be rendered on the frame you walked into it.
_list.ItemScrollEnter = item => (item as AssetEntry)?.OnScrollEnter();
_list.ItemScrollExit = item => (item as AssetEntry)?.OnScrollExit();
_list.ItemSelected = ShowItem;
_list.ItemActivated = Activate;
_list.ItemContextMenu = item => OpenCellMenu( item as AssetEntry );
_list.ItemDrag = BeginDrag;
BuildScaleStrip();
var footer = Layout.AddRow();
footer.Margin = new Sandbox.UI.Margin( 8, 2, 8, 6 );
_status = new Editor.Label( "" ) { Color = Theme.TextLight.WithAlpha( 0.6f ) };
footer.Add( _status, 1 );
Rescan( keepFolder: false );
}
public void SetStudio( PartStudio studio )
{
_studio = studio ?? new PartStudio();
Refresh();
}
// --- how big the material is -----------------------------------------------------------------
//
// The face menu is where you FIX a material that is the wrong size, because that is where you
// notice it. This is where you READ one: which of the materials the part uses have been resized,
// and to what. Same fact, two questions, and the grid is the only place that can answer the
// second — a menu shows you one slot at a time and only if you can find a face wearing it.
//
// It mirrors the mesh editor's "Texture Selection" group, minus Fit, which needs a face and has
// no meaning against a cell in a browser.
private Widget _scaleStrip;
private Editor.Label _scaleLabel;
private EffigyNumericField _scaleU;
private EffigyNumericField _scaleV;
/// <summary>The slot the strip is currently editing, or -1 when it is hidden. Held rather than
/// re-derived from the selection, because the fields fire after a rebuild has already moved the
/// selection on.</summary>
private int _scaleSlot = -1;
/// <summary>A slot was resized. Wired to the window for the same reason MaterialChanged is: the
/// panel reports the edit, the window owns the studio, the undo stack and the rebuild.</summary>
public Action<int, Vec2> ScaleChanged { get; set; }
private void BuildScaleStrip()
{
_scaleStrip = Layout.Add( new Widget( this ) );
_scaleStrip.Layout = Layout.Row();
_scaleStrip.Layout.Margin = new Sandbox.UI.Margin( 8, 2, 8, 2 );
_scaleStrip.Layout.Spacing = 4;
_scaleLabel = new Editor.Label( "" ) { Color = Theme.TextLight.WithAlpha( 0.75f ) };
_scaleStrip.Layout.Add( _scaleLabel );
_scaleStrip.Layout.AddStretchCell();
// Two fields rather than one linked number, matching the Vector2 the mesh editor shows.
// A tile is usually square and a plank never is, and a single field cannot say so.
_scaleU = new EffigyNumericField( _scaleStrip, 1f, "u" ) { Min = 0.0001f, FixedWidth = 74 };
_scaleU.ValueEdited = _ => PushScale();
_scaleU.ToolTip = "Units across one repeat";
_scaleStrip.Layout.Add( _scaleU );
_scaleV = new EffigyNumericField( _scaleStrip, 1f, "u" ) { Min = 0.0001f, FixedWidth = 74 };
_scaleV.ValueEdited = _ => PushScale();
_scaleV.ToolTip = "Units up one repeat";
_scaleStrip.Layout.Add( _scaleV );
var smaller = new Button( "", "zoom_out", _scaleStrip ) { FixedWidth = 26, ToolTip = "Half the size" };
smaller.Clicked = () => Nudge( 0.5f );
_scaleStrip.Layout.Add( smaller );
var bigger = new Button( "", "zoom_in", _scaleStrip ) { FixedWidth = 26, ToolTip = "Twice the size" };
bigger.Clicked = () => Nudge( 2f );
_scaleStrip.Layout.Add( bigger );
_scaleStrip.Visible = false;
}
/// <summary>
/// Point the strip at whatever is selected, or hide it.
///
/// UNBOUND MATERIALS HIDE IT rather than showing a disabled row. A size is a fact about a slot,
/// and a material nobody has dropped is not on one — offering a field there would take a number
/// with nowhere to put it, and the fix is to drag the material onto a face, which the grid is
/// already for.
/// </summary>
private void ShowScaleFor( object item )
{
if ( !_scaleStrip.IsValid() )
return;
var slot = item is AssetEntry entry && _slots.TryGetValue( entry, out var found ) ? found : -1;
_scaleSlot = slot;
_scaleStrip.Visible = slot >= 0;
if ( slot < 0 )
return;
var scale = MaterialScale.ScaleFor( _studio, slot );
_scaleLabel.Text = $"Slot {slot} · units per tile";
PullScale();
}
/// <summary>
/// Bring the strip up to date with the studio, on every rebuild.
///
/// TWO THINGS, and the second is the one with a trap in it. The slot can stop existing under the
/// strip — a drop elsewhere frees it — and a size field pointed at a binding that is gone is
/// worse than no field. And the number can move without the fields having touched it, because
/// the face menu resizes the same slot.
///
/// THE TRAP: this also runs on the rebuild caused by the keystroke you are still in the middle
/// of. Type 4 into a 48 and the edit lands, the model rebuilds, and writing the value back would
/// put "4" in the box under your cursor and move the caret before you reached the 8. So the
/// write goes through <see cref="PullScale"/>, which asks the field whether it is being typed in
/// first. EffigyFeatureDialog splits the same two jobs for the same reason — RefreshState after
/// every rebuild, RefreshValues only when something else is driving the number.
/// </summary>
private void RefreshScale()
{
if ( !_scaleStrip.IsValid() || _scaleSlot < 0 )
return;
if ( _studio is null || !_studio.MaterialNames.ContainsKey( _scaleSlot ) )
{
_scaleSlot = -1;
_scaleStrip.Visible = false;
return;
}
// The face menu can resize the slot this strip is pointed at, and a rebuild is all the
// notice the panel gets. So the numbers are re-read after all — just never into a box
// somebody is typing in, which is the case the split exists for.
PullScale();
}
/// <summary>Put the studio's number into the fields, unless they are being typed in.</summary>
private void PullScale()
{
if ( !_scaleStrip.IsValid() || _scaleSlot < 0 )
return;
if ( _scaleU.IsEditing || _scaleV.IsEditing )
return;
var scale = MaterialScale.ScaleFor( _studio, _scaleSlot );
// SetValue, not the text, so this cannot echo back out as an edit and drive the two in a
// loop.
_scaleU.SetValue( scale.x );
_scaleV.SetValue( scale.y );
}
private void PushScale()
{
if ( _scaleSlot >= 0 )
ScaleChanged?.Invoke( _scaleSlot, new Vec2( _scaleU.Value, _scaleV.Value ) );
}
private void Nudge( float factor )
{
if ( _scaleSlot < 0 )
return;
ScaleChanged?.Invoke( _scaleSlot, MaterialScale.ScaleFor( _studio, _scaleSlot ) * factor );
// After the invoke, because it rebuilds synchronously and the number the buttons produce is
// one nobody typed — the fields have no other way to hear about it.
PullScale();
}
/// <summary>
/// Bring the panel up to date with the studio.
///
/// This runs on EVERY rebuild, which includes every tick of a dragged parameter, so it must stay
/// cheap — and it is: what the project CONTAINS cannot change on a rebuild, so nothing here
/// touches the list's items or re-walks a folder. All that moves is which slot each material is
/// bound to, which is a dictionary of a handful of entries and a line of footer text.
/// </summary>
public void Refresh()
{
MapSlots();
ShowSummary();
RefreshScale();
// Repaint, because the badges are drawn from _slots and nothing else would ask for a frame.
_list?.Update();
}
// --- where we are standing -------------------------------------------------------------------
/// <summary>Walk into a folder, or back out to one. The path bar and the grid are both just
/// views of <see cref="_folder"/>, so this is the only thing navigation changes.</summary>
private void GoTo( string folder )
{
_folder = folder ?? "";
// The search box is scoped to where you are standing, so carrying a query through a
// navigation would land you in a folder showing a filtered subset of it with no sign of why.
_search.Text = "";
_pathBar.SetPath( _folder, RootLabel );
Populate();
}
private string RootLabel => _projectOnly ? "Project" : "All";
private void ToggleScope()
{
_projectOnly = !_projectOnly;
Rescan( keepFolder: true );
}
/// <summary>
/// Where a fresh panel opens: `materials`, when there is one.
///
/// Not the root. Every material this project owns is under `materials/`, so opening at the root
/// means the dock's first screen is one folder cell and nothing else — a click you would make
/// every single time. The path bar makes going up obvious, so nothing is hidden by starting one
/// level in.
/// </summary>
private string DefaultFolder() =>
_all.Any( e => Relative( e ).StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
? "materials"
: "";
// --- the listing ------------------------------------------------------------------------------
/// <summary>One folder in the grid: where it is, what to call it, and how many materials are
/// under it including its own subfolders. The count is the thing that makes a folder cell worth
/// clicking rather than a guess.</summary>
private sealed class FolderEntry
{
public string Path;
public string Name;
public int Count;
}
/// <summary>An asset's path, always with forward slashes, so every prefix test below compares
/// like with like.</summary>
private static string Relative( AssetEntry entry ) =>
entry.Asset?.RelativePath?.Replace( '\\', '/' ) ?? "";
/// <summary>An asset's path relative to the folder we are standing in, or null when it is not
/// under it at all. The whole folder view is this one function plus a look for the next slash.
/// </summary>
private string Within( AssetEntry entry )
{
var path = Relative( entry );
if ( _folder.Length == 0 )
return path;
return path.StartsWith( _folder + "/", StringComparison.OrdinalIgnoreCase )
? path[(_folder.Length + 1)..]
: null;
}
/// <summary>
/// Fill the grid: folders first, then the materials in this folder.
///
/// Runs on navigation and on a keystroke, not per frame, so scanning the scope's whole list is
/// the right shape — a stored tree would have to be rebuilt on every asset import and would be
/// one more thing to keep true.
/// </summary>
private void Populate()
{
var query = _search.Value?.Trim();
var here = new List<AssetEntry>();
var folders = new Dictionary<string, FolderEntry>( StringComparer.OrdinalIgnoreCase );
foreach ( var entry in _all )
{
if ( Within( entry ) is not { } rest )
continue;
// SEARCH IS RECURSIVE, and matched against the rest of the path rather than the file
// name: folders are what carry the meaning here, so typing "elite" has to find the
// twelve materials in the elite folder even though not one of them is called that.
if ( !string.IsNullOrEmpty( query ) )
{
if ( rest.Contains( query, StringComparison.OrdinalIgnoreCase ) )
here.Add( entry );
continue;
}
var cut = rest.IndexOf( '/' );
if ( cut < 0 )
{
here.Add( entry );
continue;
}
var name = rest[..cut];
if ( !folders.TryGetValue( name, out var folder ) )
{
folder = new FolderEntry
{
Name = name,
Path = _folder.Length == 0 ? name : $"{_folder}/{name}",
};
folders[name] = folder;
}
folder.Count++;
}
var items = folders.Values
.OrderBy( f => f.Name, StringComparer.OrdinalIgnoreCase )
.Cast<object>()
.Concat( here );
_list.SetItems( items );
// Back to the top: walking into a folder while scrolled halfway down the last one lands you
// in the middle of the new listing with no sign that there is anything above.
_list.ScrollTo( 0f, 0f );
ShowSummary( folders.Count, here.Count, !string.IsNullOrEmpty( query ) );
}
/// <summary>Walk the asset system again — a dock opening, an import, a scope change.</summary>
private void Rescan() => Rescan( keepFolder: true );
private void Rescan( bool keepFolder )
{
// Resolved once rather than per asset: Project.Current walks up to a config file.
var root = _projectOnly ? Project.Current?.GetAssetsPath()?.Replace( '\\', '/' ) : null;
_all = AssetSystem.All
.Where( a => a is not null && a.AssetType == AssetType.Material && InScope( a, root ) )
.OrderBy( a => a.RelativePath, StringComparer.OrdinalIgnoreCase )
.Select( a => new AssetEntry( a ) )
.ToList();
_byPath.Clear();
foreach ( var entry in _all )
{
// First wins, so the index agrees with the ordering above rather than with whichever
// duplicate the asset system happened to hand over last.
if ( MaterialDrop.Normalise( Relative( entry ) ) is { } key )
_byPath.TryAdd( key, entry );
}
MapSlots();
// A folder that exists in one scope need not exist in the other, and standing in one that
// has gone would show an empty grid with a path bar insisting you are somewhere.
if ( !keepFolder || (_folder.Length > 0 && !_all.Any( e => Within( e ) is not null )) )
_folder = DefaultFolder();
// The ICON carries the state, not just the tooltip - a button that looks identical in both
// modes makes "why can I not find that material" a question with no answer on screen. Both are
// classic Material Icons names: s&box ships MaterialIcons-Regular.ttf, and a Material *Symbols*
// name renders as nothing at all.
_scopeButton.Icon = _projectOnly ? "home" : "public";
_scopeButton.ToolTip = _projectOnly
? $"This project's materials only ({_all.Count}) — click for everything the editor can see"
: $"Every material the editor can see ({_all.Count}) — click for this project's alone";
GoTo( _folder );
}
/// <summary>Whether an asset counts as the project's own. By where it lives on disk, because
/// that is what "mine" means here — mounted games and engine content are elsewhere.</summary>
private static bool InScope( Asset asset, string root )
{
if ( root is null )
return true;
return asset.AbsolutePath?.Replace( '\\', '/' )
.StartsWith( root, StringComparison.OrdinalIgnoreCase ) ?? false;
}
// --- slots ------------------------------------------------------------------------------------
/// <summary>
/// Work out which materials the document has bound to a slot.
///
/// Walks the SLOTS and looks each one up, not the materials asking each one which slot it is on.
/// The two give the same answer and cost wildly different amounts: there are a handful of named
/// slots and over a thousand materials, and this runs on every rebuild — which is every tick of
/// a dragged parameter.
///
/// Matched through MaterialDrop.Normalise rather than by comparing strings directly, because a
/// slot named with backslashes and an asset path with forward ones are the same material. That
/// rule lives in the kernel and is used from there rather than restated here: a second copy
/// would agree with the first until one of them learned something.
///
/// Lowest slot wins if a document names two with the same material, matching SlotCarrying — the
/// badge must show the slot a drop would actually reuse.
/// </summary>
private void MapSlots()
{
_slots.Clear();
if ( _studio is null )
return;
foreach ( var (slot, name) in _studio.MaterialNames.OrderBy( kv => kv.Key ) )
{
if ( MaterialDrop.Normalise( name ) is not { } key )
continue;
if ( _byPath.TryGetValue( key, out var entry ) && !_slots.ContainsKey( entry ) )
_slots[entry] = slot;
}
}
/// <summary>What a slot carries, or null. Deliberately not NameForSlot, which substitutes
/// material_N for an unbound slot and so has no empty answer — and "is anything bound here" is
/// the question with one.</summary>
private string SlotMaterial( int slot ) =>
_studio is not null && _studio.MaterialNames.TryGetValue( slot, out var name )
&& !string.IsNullOrWhiteSpace( name )
? name
: null;
// --- what the mouse does ----------------------------------------------------------------------
/// <summary>
/// Start the drag. This is the feature.
///
/// Data.Text is the RELATIVE path, and it has to be: it is what the editor's own asset list puts
/// there, so anything in the editor that already accepts a dragged material accepts one from
/// this dock too, and it is what EffigyMaterialSlot stores when you pick through browse — two
/// routes to the same slot must not write two different spellings of the same asset.
///
/// Data.Url is the absolute path as a file:// URI, again matching the asset list. Some drop
/// targets read one, some the other, and a drag that fills in only half of it works everywhere
/// you tested and nowhere else.
/// </summary>
private bool BeginDrag( object item )
{
// Folders are not draggable. Dropping one on a face has no meaning, and a drag that starts
// and does nothing reads as the dock being broken rather than as the gesture being wrong.
if ( item is not AssetEntry entry || entry.Asset is null )
return false;
var drag = new Drag( this );
drag.Data.Text = entry.Asset.RelativePath;
drag.Data.Url = new Uri( "file:///" + entry.Asset.AbsolutePath );
drag.Execute();
return true;
}
/// <summary>Double-click: walk into a folder, or give the whole part a material. Reported rather
/// than acted on for the material case — this dock does not own the studio.</summary>
private void Activate( object item )
{
if ( item is FolderEntry folder )
{
GoTo( folder.Path );
return;
}
if ( item is AssetEntry entry && entry.Asset is { } asset )
MaterialActivated?.Invoke( asset.RelativePath );
}
/// <summary>
/// Right-click a material: bind it to a slot by hand, or take it off the one it is on.
///
/// This is where the old eight rows went. Everything they could do is here — put a material on
/// slot 5 without touching any geometry, take it off again — but reached from the material,
/// which is the thing you have in mind, rather than from a number you do not. It is also the
/// only route to a slot above seven, for a document that arrived using one.
/// </summary>
private void OpenCellMenu( AssetEntry entry )
{
if ( entry?.Asset is not { } asset || _studio is null )
return;
var menu = new Menu( this );
var path = asset.RelativePath;
var current = _slots.TryGetValue( entry, out var bound ) ? bound : -1;
menu.AddHeading( Path.GetFileName( path ) );
var whole = menu.AddOption( "Use for the whole part", "format_paint", () => MaterialActivated?.Invoke( path ) );
whole.StatusTip = "Slot 0 — every face nobody has painted";
whole.Checkable = true;
whole.Checked = current == 0;
var slots = menu.AddMenu( "Bind to slot", "layers" );
foreach ( var slot in BindableSlots( current ) )
{
var option = slots.AddOption( _studio.NameForSlot( slot ), null, () => MaterialChanged?.Invoke( slot, path ) );
option.Checkable = true;
option.Checked = slot == current;
}
if ( current >= 0 )
{
var clear = menu.AddOption( $"Unbind from slot {current}", "backspace",
() => MaterialChanged?.Invoke( current, null ) );
clear.StatusTip = $"Back to the default name — exports as {ObjWriter.DefaultMaterialName( current )}";
}
menu.OpenAtCursor();
}
/// <summary>
/// Which slots the bind menu offers: zero through seven, plus anything the document already
/// uses, plus whichever one this material is on.
///
/// Seven is not arbitrary — it is how many colours the viewport tints slots with, so every slot
/// offered is one you can tell apart on screen. The kernel allows 0..63 and nobody picks slot 40
/// off a list, but a document that arrived with one must not be unreachable.
/// </summary>
private IEnumerable<int> BindableSlots( int current )
{
var slots = new SortedSet<int>();
for ( var i = 0; i <= 7; i++ )
slots.Add( i );
foreach ( var slot in FaceMaterialEdit.UsedSlots( _studio ) )
slots.Add( slot );
if ( current >= 0 )
slots.Add( current );
return slots;
}
// --- the footer -------------------------------------------------------------------------------
private void ShowItem( object item )
{
ShowScaleFor( item );
if ( !_status.IsValid() )
return;
if ( item is FolderEntry folder )
_status.Text = $"{folder.Path} — {folder.Count} material{(folder.Count == 1 ? "" : "s")}";
else if ( item is AssetEntry entry && entry.Asset is { } asset )
_status.Text = asset.RelativePath;
}
private void ShowSummary() => ShowSummary( -1, -1, false );
/// <summary>
/// The footer: what is in front of you, and what the part is still missing.
///
/// The second half is the one thing the old row list was genuinely good at — an unbound slot was
/// visible as a gap rather than discovered on export. It does NOT survive as a bound-over-total
/// ratio, which was the obvious translation and a useless one: the total would be the slots the
/// document has an opinion about, and naming a slot is what gives it one, so the two numbers
/// would be equal almost always and would read as "everything is fine" while a slot the geometry
/// paints sat unnamed.
///
/// The number that means something is the count of slots a FaceMaterialFeature paints that
/// nobody has bound a material to. Those export as `material_4` and are exactly the thing you
/// find out about too late. Which ones they are is answered by the badges: a painted slot with
/// no material has no badge anywhere in the grid.
/// </summary>
private void ShowSummary( int folders, int materials, bool searching )
{
if ( !_status.IsValid() )
return;
if ( _all.Count == 0 )
{
_status.Text = _projectOnly
? "No materials in this project — click the box to see them all"
: "No materials found";
return;
}
// -1 means "this is a Refresh, not a Populate" — the studio changed and the listing did not,
// so the counts already on screen are still right and only the slot half needs redoing.
var listing = folders < 0
? _status.Text?.Split( '·' ).FirstOrDefault()?.Trim()
: searching
? $"{materials} match{(materials == 1 ? "" : "es")}"
: Describe( folders, materials );
var bound = _studio?.MaterialNames.Count( kv => !string.IsNullOrWhiteSpace( kv.Value ) ) ?? 0;
if ( bound == 0 )
{
_status.Text = listing;
return;
}
var unnamed = FaceMaterialEdit.UsedSlots( _studio )
.Count( slot => string.IsNullOrWhiteSpace( SlotMaterial( slot ) ) );
_status.Text = unnamed == 0
? $"{listing} · {bound} bound"
: $"{listing} · {bound} bound, {unnamed} slot{(unnamed == 1 ? "" : "s")} unnamed";
}
private static string Describe( int folders, int materials )
{
if ( folders == 0 && materials == 0 )
return "Empty folder";
var parts = new List<string>( 2 );
if ( folders > 0 )
parts.Add( $"{folders} folder{(folders == 1 ? "" : "s")}" );
if ( materials > 0 )
parts.Add( $"{materials} material{(materials == 1 ? "" : "s")}" );
return string.Join( ", ", parts );
}
// --- painting ---------------------------------------------------------------------------------
private void PaintCell( VirtualWidget item )
{
var rect = item.Rect.Shrink( 2 );
if ( Paint.HasSelected || Paint.HasPressed )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Blue.Darken( 0.4f ) );
Paint.DrawRect( rect, Theme.ControlRadius );
}
else if ( Paint.HasMouseOver )
{
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceLightBackground.WithAlpha( 0.4f ) );
Paint.DrawRect( rect, Theme.ControlRadius );
}
var icon = rect.Shrink( 4 );
icon.Height = icon.Width;
var text = rect.Shrink( 4, 0 );
text.Top = icon.Bottom + 2;
if ( item.Object is FolderEntry folder )
{
PaintFolder( icon, text, folder );
return;
}
if ( item.Object is not AssetEntry entry )
return;
Paint.BilinearFiltering = true;
entry.DrawIcon( icon );
Paint.BilinearFiltering = false;
Paint.SetDefaultFont( 7 );
Paint.ClearBrush();
Paint.SetPen( Theme.Text.WithAlpha( 0.8f ) );
var name = Path.GetFileNameWithoutExtension( entry.Name );
Paint.DrawText( text, Paint.GetElidedText( name, text.Width, ElideMode.Middle ), TextFlag.LeftTop );
if ( _slots.TryGetValue( entry, out var slot ) )
PaintSlotBadge( icon, slot );
}
/// <summary>A folder cell: the icon, the name, and the count that tells you whether walking in
/// is worth it. Drawn plainly rather than with a thumbnail because there is nothing to render —
/// and a folder that looked like a material would be dragged onto a face.</summary>
private static void PaintFolder( Rect icon, Rect text, FolderEntry folder )
{
Paint.ClearBrush();
Paint.SetPen( Theme.Yellow.WithAlpha( 0.75f ) );
Paint.DrawIcon( icon, "folder", icon.Height * 0.6f, TextFlag.Center );
Paint.SetDefaultFont( 7 );
Paint.SetPen( Theme.Text.WithAlpha( 0.9f ) );
Paint.DrawText( text, Paint.GetElidedText( folder.Name, text.Width, ElideMode.Middle ), TextFlag.LeftTop );
Paint.SetDefaultFont( 6 );
Paint.SetPen( Theme.TextLight.WithAlpha( 0.5f ) );
Paint.DrawText( icon.Shrink( 2 ), folder.Count.ToString(), TextFlag.RightBottom );
}
/// <summary>
/// The slot number, in the slot's own viewport colour.
///
/// The COLOUR is the point, more than the number: the viewport shades painted faces with a
/// per-slot palette, so a badge in the matching colour is what connects the green patch on the
/// model to the material that put it there. Slot 0 gets the neutral treatment because the
/// viewport pointedly does not tint it — it is the part's base, not a painted patch.
/// </summary>
private static void PaintSlotBadge( Rect icon, int slot )
{
var badge = new Rect( icon.Right - 20, icon.Top + 2, 18, 14 );
Paint.ClearPen();
Paint.SetBrush( slot == 0 ? Theme.ControlBackground : EffigyViewport.SlotColor( slot ) );
Paint.DrawRect( badge, 3 );
Paint.SetDefaultFont( 6 );
Paint.ClearBrush();
Paint.SetPen( slot == 0 ? Theme.Text.WithAlpha( 0.8f ) : Color.Black.WithAlpha( 0.85f ) );
Paint.DrawText( badge, slot.ToString(), TextFlag.Center );
}
/// <summary>
/// A material was added, deleted or reimported somewhere else in the editor.
///
/// Worth listening for rather than leaving to the reload button: the ordinary way to get a
/// material into an Effigy part is to make one in the material editor and then come here for it,
/// and a browser that cannot see the material you just made is a browser you stop trusting.
/// </summary>
void AssetSystem.IEventListener.OnAssetSystemChanges() => Rescan();
}
/// <summary>
/// The path bar above the material grid — an up arrow, then the folders you are standing inside,
/// each one clickable to go back to it.
///
/// HAND-PAINTED, WITH NO CHILD WIDGETS, and that is the whole design. The obvious build is a row of
/// small buttons rebuilt on every navigation, and it has a trap in it: the click handler navigates,
/// navigating rebuilds the row, and the row is rebuilt from inside the Clicked callback of one of
/// the buttons being deleted. That is the same hazard the old Materials panel documented about its
/// × button. One widget that paints text and hit-tests it cannot delete anything, so the callback is
/// free to repopulate whatever it likes.
/// </summary>
internal sealed class EffigyPathBar : Widget
{
/// <summary>Where to go. The empty string is the root.</summary>
public Action<string> Navigated { get; set; }
private string _folder = "";
private string _root = "All";
/// <summary>Segment rects and where each one leads, filled in during paint and read on click.
/// Measuring text needs a paint scope, and a bar nobody has drawn is a bar nobody can click.
/// </summary>
private readonly List<(Rect Rect, string Target)> _hits = new();
/// <summary>Where the cursor is, in this widget's own coordinates, or off it. Tracked from the
/// move events rather than read from Application at paint time, because the ambient cursor is a
/// SCREEN position and converting it back is a round trip to answer a question the event we
/// already got had the answer to.</summary>
private Vector2 _cursor = new( -1f, -1f );
private const float Height_ = 22f;
private const float UpWidth = 20f;
public EffigyPathBar( Widget parent ) : base( parent )
{
// Same pair every hand-painted widget in this tool sets: a plain Widget paints the system
// background, which here is a band across the dock.
TranslucentBackground = true;
NoSystemBackground = true;
MouseTracking = true;
Cursor = CursorShape.Finger;
FixedHeight = Height_;
}
public void SetPath( string folder, string root )
{
_folder = folder ?? "";
_root = root;
Update();
}
/// <summary>The folder one level up, or null at the root.</summary>
private string ParentFolder()
{
if ( _folder.Length == 0 )
return null;
var cut = _folder.LastIndexOf( '/' );
return cut < 0 ? "" : _folder[..cut];
}
protected override void OnPaint()
{
_hits.Clear();
var up = ParentFolder();
Paint.SetPen( Theme.TextLight.WithAlpha( up is null ? 0.25f : 0.8f ) );
Paint.ClearBrush();
Paint.DrawIcon( new Rect( 4f, 0f, UpWidth, Height ), "arrow_upward", 14, TextFlag.Center );
if ( up is not null )
_hits.Add( (new Rect( 4f, 0f, UpWidth, Height ), up) );
var x = 4f + UpWidth + 4f;
x = DrawSegment( x, _root, "", _folder.Length == 0 );
if ( _folder.Length == 0 )
return;
var walked = "";
foreach ( var segment in _folder.Split( '/' ) )
{
walked = walked.Length == 0 ? segment : $"{walked}/{segment}";
Paint.SetDefaultFont( 7 );
Paint.SetPen( Theme.TextLight.WithAlpha( 0.35f ) );
var caret = new Rect( x, 0f, 10f, Height );
Paint.DrawText( caret, "›", TextFlag.Center );
x = DrawSegment( x + 10f, segment, walked, walked == _folder );
}
}
/// <summary>One clickable name. Returns where the next one starts.</summary>
private float DrawSegment( float x, string text, string target, bool last )
{
Paint.SetDefaultFont( 7, last ? 600 : 400 );
var width = Paint.MeasureText( text ).x + 8f;
var rect = new Rect( x, 2f, width, Height - 4f );
var hovered = rect.IsInside( _cursor );
if ( hovered && !last )
{
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceLightBackground.WithAlpha( 0.4f ) );
Paint.DrawRect( rect, 3f );
}
Paint.ClearBrush();
Paint.SetPen( Theme.Text.WithAlpha( last ? 0.95f : 0.65f ) );
Paint.DrawText( rect, text, TextFlag.Center );
// The last segment is where you already are, so it is not offered as somewhere to go.
if ( !last )
_hits.Add( (rect, target) );
return x + width;
}
/// <summary>Taking the press is what guarantees the release arrives here rather than at whatever
/// is underneath — the same reason every other painted control in this tool accepts it.</summary>
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton )
e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( !e.LeftMouseButton )
return;
foreach ( var (rect, target) in _hits )
{
if ( !rect.IsInside( e.LocalPosition ) )
continue;
e.Accepted = true;
Navigated?.Invoke( target );
return;
}
}
protected override void OnMouseMove( MouseEvent e )
{
_cursor = e.LocalPosition;
Update();
}
protected override void OnMouseLeave()
{
_cursor = new Vector2( -1f, -1f );
Update();
}
}