From 0b0d93f701f581666b83ed0a9ad0471f207a1437 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:19:08 +0100 Subject: [PATCH 1/5] Rework blueprints: own folder, names, footprints, bulk save, groups Prefabs become blueprints in MultiEditorBlueprints; open, import and Designs lists save them in bulk. The Blueprints page gets a group drop-down, favorite groups and thumbnails in the canvas's order. --- .../Classes/Blueprints.cs | 398 +++++++++++ .../Classes/MultiTile.cs | 35 +- .../Classes/Prefabs.cs | 123 ---- .../Editing/EditorSession.Commands.cs | 9 +- .../UI/BlueprintSaveDialog.Designer.cs | 122 ++++ .../UI/BlueprintSaveDialog.cs | 148 ++++ .../UI/BlueprintSaveDialog.resx | 120 ++++ .../UI/DesignLibraryPanel.Designer.cs | 16 + .../UI/DesignLibraryPanel.cs | 47 +- UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs | 96 ++- .../UI/DocumentIOPickDialog.Designer.cs | 15 + .../UI/DocumentIOPickDialog.cs | 44 +- .../UI/EditorSettings.cs | 6 + .../UI/FolderImportDialog.Designer.cs | 18 +- .../UI/FolderImportDialog.cs | 39 +- .../UI/MultiEditorControl.cs | 46 +- .../UI/TileBrowserPanel.Designer.cs | 111 +-- .../UI/TileBrowserPanel.cs | 653 +++++++++++++----- .../UoFiddler.Plugin.MultiEditor.csproj | 9 + 19 files changed, 1628 insertions(+), 427 deletions(-) create mode 100644 UoFiddler.Plugin.MultiEditor/Classes/Blueprints.cs delete mode 100644 UoFiddler.Plugin.MultiEditor/Classes/Prefabs.cs create mode 100644 UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.Designer.cs create mode 100644 UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.cs create mode 100644 UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.resx diff --git a/UoFiddler.Plugin.MultiEditor/Classes/Blueprints.cs b/UoFiddler.Plugin.MultiEditor/Classes/Blueprints.cs new file mode 100644 index 00000000..87318e03 --- /dev/null +++ b/UoFiddler.Plugin.MultiEditor/Classes/Blueprints.cs @@ -0,0 +1,398 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Ultima; +using UoFiddler.Controls.Classes; +using UoFiddler.Plugin.MultiEditor.Tools; + +namespace UoFiddler.Plugin.MultiEditor.Classes +{ + /// + /// A blueprint to write: its name, how many tiles it has (as far as known before reading it, + /// for the size warning) and its tiles, read only when written. + /// + internal sealed record BlueprintSource(string Name, int TileCount, Func> Tiles); + + /// What a call wrote and what it could not. + internal sealed record BlueprintSaveResult(int Saved, List Failures); + + /// + /// Saved groups of tiles to stamp again ("blueprints"), one multi text file each in the profile's + /// MultiEditorBlueprints folder (the same lines the clipboard uses). A subfolder is a group. The + /// first line starting with # holds the blueprint's name; files without one go by their file name. + /// + internal static class Blueprints + { + /// Blueprints above this many tiles are confirmed before they are saved. + public const int LargeBlueprintTiles = 2000; + + private const string _folderName = "MultiEditorBlueprints"; + + /// The folder blueprints were kept in when they were called prefabs. + private const string _oldFolderName = "MultiEditorPrefabs"; + + private static string _migratedFrom; + + /// The blueprint folder. The old prefab folder is moved into it the first time it is asked for. + public static string Folder + { + get + { + string folder = Path.Combine(Options.AppDataPath, _folderName); + string old = Path.Combine(Options.AppDataPath, _oldFolderName); + if (_migratedFrom != old) + { + _migratedFrom = old; + MoveOldFolder(old, folder); + } + + return folder; + } + } + + /// + /// Moves the old prefab folder to the blueprint folder: renamed when there is no blueprint + /// folder yet, else merged file by file (groups too), leaving anything whose name is taken. + /// The old folder is removed once it is empty. Failures leave the files where they are. + /// + private static void MoveOldFolder(string old, string folder) + { + try + { + if (!Directory.Exists(old)) + { + return; + } + + if (!Directory.Exists(folder)) + { + Directory.Move(old, folder); + return; + } + + MergeFolder(old, folder); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The old files stay; the next start tries again. + } + } + + private static void MergeFolder(string from, string to) + { + Directory.CreateDirectory(to); + foreach (string file in Directory.EnumerateFiles(from).ToList()) + { + string target = Path.Combine(to, Path.GetFileName(file)); + if (!File.Exists(target)) + { + File.Move(file, target); + } + } + + foreach (string sub in Directory.EnumerateDirectories(from).ToList()) + { + MergeFolder(sub, Path.Combine(to, Path.GetFileName(sub))); + } + + if (!Directory.EnumerateFileSystemEntries(from).Any()) + { + Directory.Delete(from); + } + } + + /// The folder of a group; the blueprint folder itself for no group. + public static string GroupFolder(string group) + { + return string.IsNullOrEmpty(group) ? Folder : Path.Combine(Folder, group); + } + + /// The blueprint groups (subfolders), read from disk. + public static List Groups() + { + try + { + string folder = Folder; + return Directory.Exists(folder) + ? Directory.EnumerateDirectories(folder).Select(Path.GetFileName).OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList() + : new List(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new List(); + } + } + + /// The group the last bulk save went to, offered first next time; empty for no group. + public static string LastGroup { get; set; } = string.Empty; + + /// A file name made safe from what the user typed. + public static string SafeName(string name) + { + foreach (char c in Path.GetInvalidFileNameChars()) + { + name = name.Replace(c, '_'); + } + + return name.Trim(); + } + + /// + /// Reads a blueprint file: its tiles (null when the file is missing or holds a line that is + /// neither a tile nor a # line) and its name: the first # line, else the file name. + /// + public static (List Tiles, string Name) Read(string path) + { + string fileName = Path.GetFileNameWithoutExtension(path); + try + { + if (!File.Exists(path)) + { + return (null, fileName); + } + + (List tiles, string name) = Parse(File.ReadAllText(path)); + return (tiles, name.Length > 0 ? name : fileName); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return (null, fileName); + } + } + + /// Blueprint file text: its tiles and the name from its first # line (empty without one). + public static (List Tiles, string Name) Parse(string text) + { + string name = null; + var lines = new StringBuilder(); + foreach (string raw in text.Split('\n')) + { + string line = raw.Trim(); + if (line.StartsWith('#')) + { + name ??= line[1..].Trim(); + } + else if (line.Length > 0) + { + lines.Append(line).Append('\n'); + } + } + + return (ClipboardTile.Parse(lines.ToString()), name ?? string.Empty); + } + + /// The file text: the name (on one line) when there is one, then the tile lines. + public static string Format(IEnumerable tiles, string name) + { + string text = ClipboardTile.Format(tiles); + string line = OneLine(name); + return line.Length == 0 ? text : $"# {line}\r\n{text}"; + } + + public static void Write(string path, IEnumerable tiles, string name) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, Format(tiles, name)); + } + + /// + /// A free name for a blueprint in a folder and the file for it: the name as given, or with + /// " (2)", " (3)"... added when its file is taken. is a file that + /// does not count as taken (the blueprint being renamed). + /// + public static (string Path, string Name) UniqueName(string folder, string name, string own = null) + { + string baseName = OneLine(name); + if (SafeName(baseName).Length == 0) + { + baseName = "Blueprint"; + } + + string candidate = baseName; + string path = Path.Combine(folder, SafeName(candidate) + ".txt"); + for (int n = 2; File.Exists(path) && !string.Equals(Path.GetFullPath(path), own == null ? null : Path.GetFullPath(own), StringComparison.OrdinalIgnoreCase); ++n) + { + candidate = string.Create(CultureInfo.InvariantCulture, $"{baseName} ({n})"); + path = Path.Combine(folder, SafeName(candidate) + ".txt"); + } + + return (path, candidate); + } + + /// + /// How much room the tiles take: cells along X and Y, and Z from the lowest tile to the top + /// of the highest (its tiledata height included). + /// + public static (int Width, int Depth, int Height) Footprint(IReadOnlyCollection tiles) + { + if (tiles == null || tiles.Count == 0) + { + return (0, 0, 0); + } + + int minX = int.MaxValue, minY = int.MaxValue, minZ = int.MaxValue; + int maxX = int.MinValue, maxY = int.MinValue, top = int.MinValue; + foreach (ClipboardTile tile in tiles) + { + minX = Math.Min(minX, tile.Dx); + minY = Math.Min(minY, tile.Dy); + minZ = Math.Min(minZ, tile.Dz); + maxX = Math.Max(maxX, tile.Dx); + maxY = Math.Max(maxY, tile.Dy); + int height = TileData.ItemTable != null && tile.Id < TileData.ItemTable.Length ? TileData.ItemTable[tile.Id].Height : 0; + top = Math.Max(top, tile.Dz + height); + } + + return (maxX - minX + 1, maxY - minY + 1, top - minZ); + } + + /// A footprint as text: "12×9", with the height when . + public static string FootprintText((int Width, int Depth, int Height) size, bool withHeight) + { + string cells = string.Create(CultureInfo.InvariantCulture, $"{size.Width}×{size.Depth}"); + return withHeight ? string.Create(CultureInfo.InvariantCulture, $"{cells} cells, {size.Height} Z high") : cells; + } + + /// + /// A multi's tiles as blueprint tiles from its lowest X, Y and Z, keeping each tile's + /// visibility flag and extra field the way the editor reads them. + /// + public static List FromComponents(MultiComponentList list) + { + var tiles = new List(); + if (list == null || list == MultiComponentList.Empty) + { + return tiles; + } + + for (int x = 0; x < list.Width; ++x) + { + for (int y = 0; y < list.Height; ++y) + { + foreach (MTile tile in list.Tiles[x][y]) + { + tiles.Add(new ClipboardTile(tile.Id, x, y, tile.Z, tile.Flag == 0, tile.Unk1)); + } + } + } + + if (tiles.Count == 0) + { + return tiles; + } + + int minX = tiles.Min(t => t.Dx), minY = tiles.Min(t => t.Dy), minZ = tiles.Min(t => t.Dz); + return tiles.Select(t => t with { Dx = t.Dx - minX, Dy = t.Dy - minY, Dz = t.Dz - minZ }).ToList(); + } + + /// + /// Writes each source into a group's folder under a free name; a source that fails or has + /// no tiles is listed with the reason and the rest are still written. + /// + /// The group for each source; empty for no group. + public static BlueprintSaveResult SaveMany(IEnumerable sources, Func groupOf) + { + int saved = 0; + var failures = new List(); + foreach (BlueprintSource source in sources) + { + try + { + List tiles = source.Tiles(); + if (tiles == null || tiles.Count == 0) + { + failures.Add($"{source.Name}: no tiles"); + continue; + } + + string folder = GroupFolder(SafeName(groupOf(source) ?? string.Empty)); + Directory.CreateDirectory(folder); + (string path, string name) = UniqueName(folder, source.Name); + Write(path, tiles, name); + ++saved; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or EndOfStreamException or ArgumentException) + { + failures.Add($"{source.Name}: {ex.Message}"); + } + } + + return new BlueprintSaveResult(saved, failures); + } + + /// + /// A small picture of the blueprint: its visible tiles drawn the way the editor draws them, scaled to fit. + /// + public static Bitmap Thumbnail(IReadOnlyList tiles, int size) + { + var placed = new List<(ClipboardTile Tile, Bitmap Art, int X, int Y)>(); + int minX = int.MaxValue, minY = int.MaxValue, maxX = int.MinValue, maxY = int.MinValue; + foreach (ClipboardTile tile in tiles) + { + Bitmap art = tile.Invisible ? null : Art.GetStatic(tile.Id); + if (art == null) + { + continue; + } + + int x = ((tile.Dx - tile.Dy) * 22) - (art.Width / 2); + int y = ((tile.Dx + tile.Dy) * 22) - (tile.Dz * 4) - art.Height; + placed.Add((tile, art, x, y)); + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x + art.Width); + maxY = Math.Max(maxY, y + art.Height); + } + + var thumb = new Bitmap(size, size); + if (placed.Count == 0) + { + return thumb; + } + + using var full = new Bitmap(Math.Max(1, maxX - minX), Math.Max(1, maxY - minY)); + using (Graphics g = Graphics.FromImage(full)) + { + // The editor's stacking order (MultiTile.CompareTo): back to front by cell diagonal, then + // Z raised by the sort threshold, then the threshold, then file order (OrderBy is stable). + foreach (var p in placed.OrderBy(p => p.Tile.Dx + p.Tile.Dy) + .ThenBy(p => p.Tile.Dz + MultiTile.SortThresholdOf(p.Tile.Id)) + .ThenBy(p => MultiTile.SortThresholdOf(p.Tile.Id))) + { + g.DrawImageUnscaled(p.Art, p.X - minX, p.Y - minY); + } + } + + using (Graphics g = Graphics.FromImage(thumb)) + { + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; + float scale = Math.Min(1f, Math.Min((float)size / full.Width, (float)size / full.Height)); + int w = Math.Max(1, (int)(full.Width * scale)); + int h = Math.Max(1, (int)(full.Height * scale)); + g.DrawImage(full, (size - w) / 2, (size - h) / 2, w, h); + } + + return thumb; + } + + private static string OneLine(string text) + { + return string.IsNullOrWhiteSpace(text) ? string.Empty : string.Join(' ', text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)).Trim(); + } + } +} diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiTile.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiTile.cs index bdf71aec..bdacb6ab 100644 --- a/UoFiddler.Plugin.MultiEditor/Classes/MultiTile.cs +++ b/UoFiddler.Plugin.MultiEditor/Classes/MultiTile.cs @@ -168,24 +168,33 @@ public int CompareTo(object obj) /// The height a tile sorts at in its cell's stack: its Z, raised for tall and non-background tiles. public int SortKey => Z + SortThreshold; - private int SortThreshold + private int SortThreshold => SortThresholdOf(Id); + + /// + /// What a tile adds to its Z when its cell is stacked: one for a tile with height, one for a + /// tile that is not background. So foliage (low, not background) goes over its trunk (tall, + /// background) at the same Z. Shared with the blueprint thumbnails so they stack the same way. + /// + public static int SortThresholdOf(ushort id) { - get + if (TileData.ItemTable == null || id >= TileData.ItemTable.Length) { - ItemData data = TileData.ItemTable[Id]; - int threshold = 0; - if (data.Height > 0) - { - ++threshold; - } + return 0; + } - if (!data.Background) - { - ++threshold; - } + ItemData data = TileData.ItemTable[id]; + int threshold = 0; + if (data.Height > 0) + { + ++threshold; + } - return threshold; + if (!data.Background) + { + ++threshold; } + + return threshold; } public void Set(ushort id, int z) diff --git a/UoFiddler.Plugin.MultiEditor/Classes/Prefabs.cs b/UoFiddler.Plugin.MultiEditor/Classes/Prefabs.cs deleted file mode 100644 index ad9a9449..00000000 --- a/UoFiddler.Plugin.MultiEditor/Classes/Prefabs.cs +++ /dev/null @@ -1,123 +0,0 @@ -/*************************************************************************** - * - * $Author: Turley - * - * "THE BEER-WARE LICENSE" - * As long as you retain this notice you can do whatever you want with - * this stuff. If we meet some day, and you think this stuff is worth it, - * you can buy me a beer in return. - * - ***************************************************************************/ - -using System; -using System.Collections.Generic; -using System.Drawing; -using System.IO; -using System.Linq; -using Ultima; -using UoFiddler.Controls.Classes; -using UoFiddler.Plugin.MultiEditor.Tools; - -namespace UoFiddler.Plugin.MultiEditor.Classes -{ - /// - /// Saved groups of tiles to stamp again, one multi text file per prefab in the profile's - /// MultiEditorPrefabs folder (the same lines the clipboard uses). - /// - internal static class Prefabs - { - public static string Folder => Path.Combine(Options.AppDataPath, "MultiEditorPrefabs"); - - public static IEnumerable Names() - { - return Directory.Exists(Folder) - ? Directory.EnumerateFiles(Folder, "*.txt").Select(Path.GetFileNameWithoutExtension).OrderBy(n => n, StringComparer.OrdinalIgnoreCase) - : Enumerable.Empty(); - } - - public static List Load(string name) - { - string path = PathOf(name); - return File.Exists(path) ? ClipboardTile.Parse(File.ReadAllText(path)) : null; - } - - public static void Save(string name, IEnumerable tiles) - { - Directory.CreateDirectory(Folder); - File.WriteAllText(PathOf(name), ClipboardTile.Format(tiles)); - } - - public static void Delete(string name) - { - string path = PathOf(name); - if (File.Exists(path)) - { - File.Delete(path); - } - } - - /// A file name made safe from what the user typed. - public static string SafeName(string name) - { - foreach (char c in Path.GetInvalidFileNameChars()) - { - name = name.Replace(c, '_'); - } - - return name.Trim(); - } - - /// - /// A small picture of the prefab: its tiles drawn the way the editor draws them, scaled to fit. - /// - public static Bitmap Thumbnail(IReadOnlyList tiles, int size) - { - var placed = new List<(ClipboardTile Tile, Bitmap Art, int X, int Y)>(); - int minX = int.MaxValue, minY = int.MaxValue, maxX = int.MinValue, maxY = int.MinValue; - foreach (ClipboardTile tile in tiles) - { - Bitmap art = Art.GetStatic(tile.Id); - if (art == null) - { - continue; - } - - int x = ((tile.Dx - tile.Dy) * 22) - (art.Width / 2); - int y = ((tile.Dx + tile.Dy) * 22) - (tile.Dz * 4) - art.Height; - placed.Add((tile, art, x, y)); - minX = Math.Min(minX, x); - minY = Math.Min(minY, y); - maxX = Math.Max(maxX, x + art.Width); - maxY = Math.Max(maxY, y + art.Height); - } - - var thumb = new Bitmap(size, size); - if (placed.Count == 0) - { - return thumb; - } - - using var full = new Bitmap(Math.Max(1, maxX - minX), Math.Max(1, maxY - minY)); - using (Graphics g = Graphics.FromImage(full)) - { - foreach (var p in placed.OrderBy(p => p.Tile.Dx + p.Tile.Dy).ThenBy(p => p.Tile.Dz).ThenBy(p => TileData.ItemTable[p.Tile.Id].Height)) - { - g.DrawImageUnscaled(p.Art, p.X - minX, p.Y - minY); - } - } - - using (Graphics g = Graphics.FromImage(thumb)) - { - g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; - float scale = Math.Min(1f, Math.Min((float)size / full.Width, (float)size / full.Height)); - int w = Math.Max(1, (int)(full.Width * scale)); - int h = Math.Max(1, (int)(full.Height * scale)); - g.DrawImage(full, (size - w) / 2, (size - h) / 2, w, h); - } - - return thumb; - } - - private static string PathOf(string name) => Path.Combine(Folder, SafeName(name) + ".txt"); - } -} diff --git a/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.Commands.cs b/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.Commands.cs index dad30bb4..89a19463 100644 --- a/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.Commands.cs +++ b/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.Commands.cs @@ -104,12 +104,19 @@ public void Paste() public bool CanPaste => _clipboard != null || ClipboardHasTiles(); - /// The selection as tiles relative to its lowest X, Y and Z (also used to save a prefab). + /// The selection as tiles relative to its lowest X, Y and Z (also used to save a blueprint). public List SelectionAsClipboard() { return _selection.Count == 0 ? null : ToClipboard(_selection); } + /// Every tile of the design, relative to its lowest X, Y and Z; null when it is empty. + public List DesignAsClipboard() + { + List tiles = CompList.RealTiles.ToList(); + return tiles.Count == 0 ? null : ToClipboard(tiles); + } + private static List ToClipboard(IReadOnlyCollection tiles) { int ox = tiles.Min(t => t.X); diff --git a/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.Designer.cs new file mode 100644 index 00000000..0ef2955b --- /dev/null +++ b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.Designer.cs @@ -0,0 +1,122 @@ +namespace UoFiddler.Plugin.MultiEditor.UI +{ + partial class BlueprintSaveDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + _summary = new System.Windows.Forms.Label(); + _groupLabel = new System.Windows.Forms.Label(); + _group = new System.Windows.Forms.ComboBox(); + _split = new System.Windows.Forms.CheckBox(); + _okButton = new System.Windows.Forms.Button(); + _cancelButton = new System.Windows.Forms.Button(); + SuspendLayout(); + // + // _summary + // + _summary.Location = new System.Drawing.Point(12, 12); + _summary.Name = "_summary"; + _summary.Size = new System.Drawing.Size(356, 46); + _summary.TabIndex = 0; + // + // _groupLabel + // + _groupLabel.AutoSize = true; + _groupLabel.Location = new System.Drawing.Point(12, 68); + _groupLabel.Name = "_groupLabel"; + _groupLabel.Size = new System.Drawing.Size(43, 15); + _groupLabel.TabIndex = 1; + _groupLabel.Text = "Group:"; + // + // _group + // + _group.FormattingEnabled = true; + _group.Location = new System.Drawing.Point(70, 64); + _group.Name = "_group"; + _group.Size = new System.Drawing.Size(298, 23); + _group.TabIndex = 2; + // + // _split + // + _split.Location = new System.Drawing.Point(12, 96); + _split.Name = "_split"; + _split.Size = new System.Drawing.Size(356, 38); + _split.TabIndex = 3; + _split.UseVisualStyleBackColor = true; + _split.Visible = false; + // + // _okButton + // + _okButton.DialogResult = System.Windows.Forms.DialogResult.OK; + _okButton.Location = new System.Drawing.Point(208, 142); + _okButton.Name = "_okButton"; + _okButton.Size = new System.Drawing.Size(76, 23); + _okButton.TabIndex = 4; + _okButton.Text = "Save"; + // + // _cancelButton + // + _cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel; + _cancelButton.Location = new System.Drawing.Point(292, 142); + _cancelButton.Name = "_cancelButton"; + _cancelButton.Size = new System.Drawing.Size(76, 23); + _cancelButton.TabIndex = 5; + _cancelButton.Text = "Cancel"; + // + // BlueprintSaveDialog + // + AcceptButton = _okButton; + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + CancelButton = _cancelButton; + ClientSize = new System.Drawing.Size(380, 177); + Controls.Add(_summary); + Controls.Add(_groupLabel); + Controls.Add(_group); + Controls.Add(_split); + Controls.Add(_okButton); + Controls.Add(_cancelButton); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "BlueprintSaveDialog"; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Save as blueprints"; + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label _summary; + private System.Windows.Forms.Label _groupLabel; + private System.Windows.Forms.ComboBox _group; + private System.Windows.Forms.CheckBox _split; + private System.Windows.Forms.Button _okButton; + private System.Windows.Forms.Button _cancelButton; + } +} diff --git a/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.cs b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.cs new file mode 100644 index 00000000..2155905e --- /dev/null +++ b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.cs @@ -0,0 +1,148 @@ +/*************************************************************************** + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Windows.Forms; +using UoFiddler.Plugin.MultiEditor.Classes; + +namespace UoFiddler.Plugin.MultiEditor.UI +{ + /// + /// Asks which blueprint group several multis are saved to, optionally one group each by + /// something the caller knows (a design's category, the file it came from). Shown through + /// . + /// + internal partial class BlueprintSaveDialog : Form + { + private const string _noGroup = "(no group)"; + private const string _caption = "Save as blueprints"; + + public BlueprintSaveDialog() + { + InitializeComponent(); + } + + /// The text of the "one group each" check box; null hides it. + public BlueprintSaveDialog(string summary, IEnumerable groups, string group, string splitText) + : this() + { + _summary.Text = summary; + _group.Items.Add(_noGroup); + foreach (string name in groups) + { + _group.Items.Add(name); + } + + _group.Text = string.IsNullOrEmpty(group) ? _noGroup : group; + if (splitText != null) + { + _split.Text = splitText; + _split.Visible = true; + } + } + + /// The group typed or chosen, made safe for a folder name; empty for no group. + public string Group + { + get + { + string text = _group.Text.Trim(); + return text.Length == 0 || text == _noGroup ? string.Empty : Blueprints.SafeName(text); + } + } + + /// Whether each multi goes to its own group, as the caller decides. + public bool Split => _split.Visible && _split.Checked; + + /// + /// Asks for the group and writes the blueprints, then lists any that could not be written. + /// Null when cancelled. + /// + /// The group to show afterwards: the one chosen, or null (all) when split. + /// Offers one group per source with this check box text, using . + /// The group of a source when split; empty puts it in the chosen group. + public static BlueprintSaveResult Run(IWin32Window owner, IReadOnlyList sources, out string showGroup, + string splitText = null, Func splitGroup = null) + { + showGroup = null; + if (sources.Count == 0) + { + return null; + } + + int large = sources.Count(s => s.TileCount > Blueprints.LargeBlueprintTiles); + string summary = sources.Count == 1 ? $"Save \"{sources[0].Name}\" as a blueprint." : $"Save {sources.Count:N0} multis as blueprints."; + if (large > 0) + { + summary += large == sources.Count && large == 1 + ? $" It has {sources[0].TileCount:N0} tiles, which is slow to place and preview." + : $" {large:N0} of them have more than {Blueprints.LargeBlueprintTiles:N0} tiles, which is slow to place and preview."; + } + + string group; + bool split; + using (var dialog = new BlueprintSaveDialog(summary, Blueprints.Groups(), Blueprints.LastGroup, splitGroup != null ? splitText : null)) + { + if (dialog.ShowDialog(owner) != DialogResult.OK) + { + return null; + } + + group = dialog.Group; + split = dialog.Split; + } + + Blueprints.LastGroup = group; + Func groupOf = split + ? s => splitGroup(s) is { Length: > 0 } own ? own : group + : _ => group; + + BlueprintSaveResult result; + Cursor previous = Cursor.Current; + Cursor.Current = Cursors.WaitCursor; + try + { + result = Blueprints.SaveMany(sources, groupOf); + } + finally + { + Cursor.Current = previous; + } + + if (result.Failures.Count > 0) + { + var text = new StringBuilder($"{result.Saved:N0} saved, {result.Failures.Count:N0} could not be saved:\r\n\r\n"); + foreach (string failure in result.Failures.Take(20)) + { + text.Append(failure).Append("\r\n"); + } + + if (result.Failures.Count > 20) + { + text.Append($"...and {result.Failures.Count - 20:N0} more"); + } + + MessageBox.Show(owner, text.ToString(), _caption, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + + showGroup = split ? null : group; + return result; + } + + /// The status line after a save, for the dialog the multis were picked in. + public static string Describe(BlueprintSaveResult result, string showGroup) + { + string where = showGroup == null ? "their groups" : showGroup.Length == 0 ? "no group" : $"group \"{showGroup}\""; + return $"Saved {result.Saved:N0} blueprint(s) to {where}."; + } + } +} diff --git a/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.resx b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.resx new file mode 100644 index 00000000..8b2ff64a --- /dev/null +++ b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.Designer.cs index c83b734c..30193b50 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.Designer.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.Designer.cs @@ -41,6 +41,7 @@ private void InitializeComponent() buttons = new System.Windows.Forms.FlowLayoutPanel(); _open = new System.Windows.Forms.Button(); _place = new System.Windows.Forms.Button(); + _saveBlueprints = new System.Windows.Forms.Button(); _filter = new System.Windows.Forms.TextBox(); bar = new System.Windows.Forms.ToolStrip(); chooseButton = new System.Windows.Forms.ToolStripButton(); @@ -142,6 +143,9 @@ private void InitializeComponent() // buttons.Controls.Add(_open); buttons.Controls.Add(_place); + buttons.Controls.Add(_saveBlueprints); + buttons.AutoSize = true; + buttons.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; buttons.Dock = System.Windows.Forms.DockStyle.Bottom; buttons.Location = new System.Drawing.Point(0, 118); buttons.Name = "buttons"; @@ -170,6 +174,17 @@ private void InitializeComponent() _place.UseVisualStyleBackColor = true; _place.Click += Place_Click; // + // _saveBlueprints + // + _saveBlueprints.Enabled = false; + _saveBlueprints.Location = new System.Drawing.Point(3, 67); + _saveBlueprints.Name = "_saveBlueprints"; + _saveBlueprints.Size = new System.Drawing.Size(150, 26); + _saveBlueprints.TabIndex = 2; + _saveBlueprints.Text = "Save as blueprints..."; + _saveBlueprints.UseVisualStyleBackColor = true; + _saveBlueprints.Click += SaveBlueprints_Click; + // // _filter // _filter.Dock = System.Windows.Forms.DockStyle.Top; @@ -252,6 +267,7 @@ private void InitializeComponent() private System.Windows.Forms.FlowLayoutPanel buttons; private System.Windows.Forms.Button _open; private System.Windows.Forms.Button _place; + private System.Windows.Forms.Button _saveBlueprints; private System.Windows.Forms.TextBox _filter; private System.Windows.Forms.ToolStrip bar; private System.Windows.Forms.ToolStripButton chooseButton; diff --git a/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.cs b/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.cs index 1859cbca..18499ded 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.cs @@ -120,6 +120,46 @@ private void Place_Click(object sender, EventArgs e) } } + /// Saves the selected designs as blueprints, optionally one group per design category. + private void SaveBlueprints_Click(object sender, EventArgs e) + { + List designs = SelectedDesigns; + if (designs.Count == 0) + { + return; + } + + var designOf = new Dictionary(); + var sources = new List(); + foreach (UoaDesign design in designs) + { + var source = new BlueprintSource(DocumentIO.DesignTitle(design), design.ItemCount, + () => Blueprints.FromComponents(UoaDesignFile.Load(design))); + sources.Add(source); + designOf[source] = design; + } + + if (BlueprintSaveDialog.Run(FindForm(), sources, out string showGroup, "One group per design category (designs without one go to the group above)", + s => CategoryGroup(designOf[s])) is { } result) + { + _info.Text = BlueprintSaveDialog.Describe(result, showGroup); + BlueprintsSaved?.Invoke(showGroup); + } + } + + /// The group a design goes to by its category: "Category - Subsection", empty without a category. + private static string CategoryGroup(UoaDesign design) + { + string category = design.Category?.Trim() ?? string.Empty; + string subsection = design.Subsection?.Trim() ?? string.Empty; + if (category.Length == 0) + { + return subsection; + } + + return Blueprints.SafeName(subsection.Length == 0 ? category : $"{category} - {subsection}"); + } + private void PreviewTimer_Tick(object sender, EventArgs e) { _previewTimer.Stop(); @@ -132,6 +172,9 @@ private void PreviewTimer_Tick(object sender, EventArgs e) /// Place the design into the multi being edited. public event Action PlaceRequested; + /// Designs were saved as blueprints; carries the group to show (null for all). + public event Action BlueprintsSaved; + private List SelectedDesigns { get @@ -307,6 +350,8 @@ private void OnSelectionChanged() // Placing is a paste under the cursor: one design at a time. _place.Enabled = count == 1; + _saveBlueprints.Enabled = design != null; + _saveBlueprints.Text = count > 1 ? $"Save {count} as blueprints..." : "Save as blueprint..."; if (design == null) { SetPreview(null); @@ -341,7 +386,7 @@ private void ShowPreview() try { MultiComponentList multi = UoaDesignFile.Load(design); - SetPreview(multi == MultiComponentList.Empty ? null : Prefabs.Thumbnail(ToTiles(multi), _previewSize)); + SetPreview(multi == MultiComponentList.Empty ? null : Blueprints.Thumbnail(ToTiles(multi), _previewSize)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs b/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs index b2b5ebbd..e0d16f7c 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs @@ -51,9 +51,10 @@ private static readonly (Multis.ImportType Type, string Extension, string Descri /// /// Lists the client's multis (multi.mul and the UOP collection when present) and returns the chosen one. - /// Null when cancelled. + /// Null when cancelled. The list can also save the chosen multis as blueprints, after which + /// gets the group to show (null for all). /// - public static List OpenFromClient(IWin32Window owner) + public static List OpenFromClient(IWin32Window owner, Action blueprintsSaved = null) { Dictionary names = LoadNames(); var entries = new List(); @@ -94,15 +95,15 @@ public static List OpenFromClient(IWin32Window owner) return null; } - return Pick(owner, "Open multi from client", entries, true, true).ConvertAll(p => new OpenedMulti(p.Components, p.Name, p.Id)); + return Pick(owner, "Open multi from client", entries, true, true, SaveEntriesAction(blueprintsSaved)) + .ConvertAll(p => new OpenedMulti(p.Components, p.Name, p.Id)); } /// - /// Asks for one or more files of a supported import type and reads them. A file that fails - /// is skipped and listed in the result; the multis of files that hold several are chosen - /// from a list. Null when cancelled. + /// Asks for one or more files of a supported import type. Null when cancelled. Formats share + /// extensions, so the type is the selected filter entry's, for every file. /// - public static ImportResult ImportFromFile(IWin32Window owner) + private static (string[] Files, Multis.ImportType Type)? AskImportFiles(IWin32Window owner, string title) { var filters = new List(); foreach ((_, string description, string pattern) in MultiFileImport.Formats) @@ -112,21 +113,74 @@ public static ImportResult ImportFromFile(IWin32Window owner) using var dialog = new OpenFileDialog { - Title = "Import multis from files", + Title = title, Multiselect = true, CheckFileExists = true, Filter = string.Join("|", filters), FilterIndex = 1 }; - if (dialog.ShowDialog(owner) != DialogResult.OK) + return dialog.ShowDialog(owner) == DialogResult.OK + ? (dialog.FileNames, MultiFileImport.Formats[dialog.FilterIndex - 1].Type) + : null; + } + + /// A multi read from a file, as a blueprint with its name; its items are read when it is written. + public static BlueprintSource BlueprintFromImport(ImportedMulti multi) + { + return new BlueprintSource(multi.Name, multi.TileCount, () => Blueprints.FromComponents(multi.Components)); + } + + /// A picked row as a blueprint, with the name the list shows for it. + private static BlueprintSource BlueprintFromEntry(MultiPickEntry entry) + { + return new BlueprintSource(entry.Name, entry.Components.SortedTiles?.Length ?? 0, () => Blueprints.FromComponents(entry.Components)); + } + + /// The pick list's "Save as blueprints" action; null (no button) without a callback. + private static Func, string> SaveEntriesAction(Action blueprintsSaved, Func fileOf = null) + { + if (blueprintsSaved == null) { return null; } - // Formats share extensions, so the type is the selected filter entry's, for every file. - Multis.ImportType type = MultiFileImport.Formats[dialog.FilterIndex - 1].Type; - string[] files = dialog.FileNames; + return (dialog, picked) => + { + List sources = picked.ConvertAll(BlueprintFromEntry); + var entryOf = new Dictionary(); + for (int i = 0; i < sources.Count; ++i) + { + entryOf[sources[i]] = picked[i]; + } + + BlueprintSaveResult result = fileOf == null + ? BlueprintSaveDialog.Run(dialog, sources, out string showGroup) + : BlueprintSaveDialog.Run(dialog, sources, out showGroup, "One group per file", s => fileOf(entryOf[s])); + if (result == null) + { + return null; + } + + blueprintsSaved(showGroup); + return BlueprintSaveDialog.Describe(result, showGroup); + }; + } + + /// + /// Asks for one or more files of a supported import type and reads them. A file that fails + /// is skipped and listed in the result; the multis of files that hold several are chosen + /// from a list, which can also save them as blueprints. Null when cancelled. + /// + public static ImportResult ImportFromFile(IWin32Window owner, Action blueprintsSaved = null) + { + if (AskImportFiles(owner, "Import multis from files") is not { } picked) + { + return null; + } + + Multis.ImportType type = picked.Type; + string[] files = picked.Files; var opened = new List(); var failures = new List(); var choices = new List(); @@ -181,16 +235,21 @@ public static ImportResult ImportFromFile(IWin32Window owner) if (choices.Count > 0) { string title = files.Length == 1 ? $"Choose multis from {Path.GetFileName(files[0])}" : "Choose multis to import"; - opened.AddRange(Pick(owner, title, choices, files.Length > 1, false).ConvertAll(p => new OpenedMulti(p.Components, p.Name, null))); + opened.AddRange(Pick(owner, title, choices, files.Length > 1, false, + SaveEntriesAction(blueprintsSaved, files.Length > 1 ? e => Path.GetFileNameWithoutExtension(e.Source) : null)) + .ConvertAll(p => new OpenedMulti(p.Components, p.Name, null))); } return new ImportResult(opened, failures); } - /// Lists the multis in every supported file under a folder and returns the chosen ones. Null when cancelled. - public static ImportResult ImportFromFolder(IWin32Window owner) + /// + /// Lists the multis in every supported file under a folder and returns the chosen ones. Null when cancelled. + /// The list can also save the chosen multis as blueprints. + /// + public static ImportResult ImportFromFolder(IWin32Window owner, Action blueprintsSaved = null) { - using var dialog = new FolderImportDialog(); + using var dialog = new FolderImportDialog(blueprintsSaved); return dialog.ShowDialog(owner) == DialogResult.OK ? new ImportResult(dialog.Opened, dialog.Failures) : null; } @@ -404,9 +463,10 @@ public static List ExtractFromMap(IWin32Window owner, Map map, Po } /// The rows picked in the list (several with Ctrl or Shift); empty when cancelled. - private static List Pick(IWin32Window owner, string title, List entries, bool showSource, bool clientSources) + private static List Pick(IWin32Window owner, string title, List entries, bool showSource, bool clientSources, + Func, string> saveBlueprints = null) { - using var dialog = new DocumentIOPickDialog(title, entries, showSource, clientSources); + using var dialog = new DocumentIOPickDialog(title, entries, showSource, clientSources, saveBlueprints); return dialog.ShowDialog(owner) == DialogResult.OK ? new List(dialog.SelectedEntries) : new List(); } diff --git a/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.Designer.cs index b08c9c8d..e1efacac 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.Designer.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.Designer.cs @@ -37,6 +37,7 @@ private void InitializeComponent() _preview = new System.Windows.Forms.PictureBox(); _info = new System.Windows.Forms.Label(); _ok = new System.Windows.Forms.Button(); + _saveBlueprints = new System.Windows.Forms.Button(); cancel = new System.Windows.Forms.Button(); sourceAll = new System.Windows.Forms.RadioButton(); sourceMul = new System.Windows.Forms.RadioButton(); @@ -122,6 +123,18 @@ private void InitializeComponent() _ok.UseVisualStyleBackColor = true; _ok.Click += Ok_Click; // + // _saveBlueprints + // + _saveBlueprints.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; + _saveBlueprints.Location = new System.Drawing.Point(445, 425); + _saveBlueprints.Name = "_saveBlueprints"; + _saveBlueprints.Size = new System.Drawing.Size(145, 23); + _saveBlueprints.TabIndex = 10; + _saveBlueprints.Text = "Save as blueprints..."; + _saveBlueprints.UseVisualStyleBackColor = true; + _saveBlueprints.Visible = false; + _saveBlueprints.Click += SaveBlueprints_Click; + // // cancel // cancel.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; @@ -183,6 +196,7 @@ private void InitializeComponent() Controls.Add(_preview); Controls.Add(_info); Controls.Add(_ok); + Controls.Add(_saveBlueprints); Controls.Add(cancel); Controls.Add(sourceAll); Controls.Add(sourceMul); @@ -209,6 +223,7 @@ private void InitializeComponent() private System.Windows.Forms.PictureBox _preview; private System.Windows.Forms.Label _info; private System.Windows.Forms.Button _ok; + private System.Windows.Forms.Button _saveBlueprints; private System.Windows.Forms.Button cancel; private System.Windows.Forms.RadioButton sourceAll; private System.Windows.Forms.RadioButton sourceMul; diff --git a/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.cs b/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.cs index e0853de5..130be268 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.cs @@ -27,16 +27,24 @@ internal sealed partial class DocumentIOPickDialog : Form { private readonly List _entries; private readonly List _sourceButtons = new List(); + private readonly Func, string> _saveBlueprintsAction; private string _sourceFilter; /// Shows the File column: which file each multi comes from. /// Adds the All / multi.mul / UOP buttons, for the client's multis. - public DocumentIOPickDialog(string title, List entries, bool showSource, bool clientSources) + /// + /// Adds a "Save as blueprints" button that hands it the selected rows; it returns the text + /// to show afterwards, or null when nothing was saved. The dialog stays open. + /// + public DocumentIOPickDialog(string title, List entries, bool showSource, bool clientSources, + Func, string> saveBlueprints = null) { _entries = entries; + _saveBlueprintsAction = saveBlueprints; InitializeComponent(); Text = title; + _saveBlueprints.Visible = saveBlueprints != null; // Which file the multis come from, as push buttons: all, multi.mul or the UOP collection. // A client without MultiCollection.uop (or without multi.mul) has that button greyed out. @@ -151,6 +159,15 @@ private void Ok_Click(object sender, EventArgs e) Accept(); } + private void SaveBlueprints_Click(object sender, EventArgs e) + { + List picked = Picked(); + if (picked.Count > 0 && _saveBlueprintsAction(this, picked) is { } done) + { + _info.Text = done; + } + } + private void Populate() { string text = _filter.Text.Trim(); @@ -205,7 +222,7 @@ private void ShowPreview() old?.Dispose(); MultiPickEntry entry = Current(); - _ok.Enabled = entry != null; + _ok.Enabled = _saveBlueprints.Enabled = entry != null; if (entry == null) { _info.Text = string.Empty; @@ -232,22 +249,29 @@ private MultiPickEntry Current() return _list.SelectedItems.Count > 0 ? _list.SelectedItems[0].Tag as MultiPickEntry : null; } - private void Accept() + private List Picked() { - if (_list.SelectedItems.Count == 0) - { - return; - } - - SelectedEntries.Clear(); + var picked = new List(); foreach (ListViewItem item in _list.SelectedItems) { if (item.Tag is MultiPickEntry entry) { - SelectedEntries.Add(entry); + picked.Add(entry); } } + return picked; + } + + private void Accept() + { + if (_list.SelectedItems.Count == 0) + { + return; + } + + SelectedEntries.Clear(); + SelectedEntries.AddRange(Picked()); DialogResult = DialogResult.OK; Close(); } diff --git a/UoFiddler.Plugin.MultiEditor/UI/EditorSettings.cs b/UoFiddler.Plugin.MultiEditor/UI/EditorSettings.cs index 5abf696b..5b1006ca 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/EditorSettings.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/EditorSettings.cs @@ -84,6 +84,12 @@ internal sealed class EditorSettings /// Whether Import from folder also scans the folders below. public bool ImportRecursive { get; set; } = true; + /// + /// Blueprint groups shown as buttons beside the group drop-down, joined by '|' (a character + /// folder names cannot hold). + /// + public string FavoriteBlueprintGroups { get; set; } = string.Empty; + /// Height of the pieces list under the House page's styles. public int HousePiecesHeight { get; set; } = 250; diff --git a/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.Designer.cs index 1de665f5..50174df9 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.Designer.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.Designer.cs @@ -47,6 +47,7 @@ private void InitializeComponent() _info = new System.Windows.Forms.Label(); _status = new System.Windows.Forms.Label(); _ok = new System.Windows.Forms.Button(); + _saveBlueprints = new System.Windows.Forms.Button(); cancel = new System.Windows.Forms.Button(); _previewTimer = new System.Windows.Forms.Timer(components); ((System.ComponentModel.ISupportInitialize)_preview).BeginInit(); @@ -207,9 +208,22 @@ private void InitializeComponent() _status.AutoEllipsis = true; _status.Location = new System.Drawing.Point(10, 524); _status.Name = "_status"; - _status.Size = new System.Drawing.Size(700, 18); + _status.Size = new System.Drawing.Size(540, 18); _status.TabIndex = 11; // + // _saveBlueprints + // + _saveBlueprints.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; + _saveBlueprints.Enabled = false; + _saveBlueprints.Location = new System.Drawing.Point(560, 520); + _saveBlueprints.Name = "_saveBlueprints"; + _saveBlueprints.Size = new System.Drawing.Size(155, 25); + _saveBlueprints.TabIndex = 14; + _saveBlueprints.Text = "Save as blueprints..."; + _saveBlueprints.UseVisualStyleBackColor = true; + _saveBlueprints.Visible = false; + _saveBlueprints.Click += SaveBlueprints_Click; + // // _ok // _ok.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; @@ -256,6 +270,7 @@ private void InitializeComponent() Controls.Add(_info); Controls.Add(_status); Controls.Add(_ok); + Controls.Add(_saveBlueprints); Controls.Add(cancel); FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable; MinimizeBox = false; @@ -289,6 +304,7 @@ private void InitializeComponent() private System.Windows.Forms.Label _info; private System.Windows.Forms.Label _status; private System.Windows.Forms.Button _ok; + private System.Windows.Forms.Button _saveBlueprints; private System.Windows.Forms.Button cancel; private System.Windows.Forms.Timer _previewTimer; } diff --git a/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.cs b/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.cs index aa65b283..23c2e975 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.cs @@ -17,6 +17,7 @@ using System.Threading.Tasks; using System.Windows.Forms; using Ultima; +using UoFiddler.Plugin.MultiEditor.Classes; namespace UoFiddler.Plugin.MultiEditor.UI { @@ -65,10 +66,21 @@ private sealed record FormatChoice(string Key, string Text) private int _multiCount; private int _failedCount; private bool _bulkSelecting; + private readonly Action _blueprintsSaved; public FolderImportDialog() + : this(null) + { + } + + /// + /// Adds a "Save as blueprints" button; after a save it gets the group to show (null for all). + /// + public FolderImportDialog(Action blueprintsSaved) { InitializeComponent(); + _blueprintsSaved = blueprintsSaved; + _saveBlueprints.Visible = blueprintsSaved != null; EditorSettings settings = EditorSettings.Current; _folder.Text = settings.ImportFolder ?? string.Empty; @@ -255,6 +267,31 @@ private void Ok_Click(object sender, EventArgs e) Accept(); } + /// Saves the selected multis as blueprints, optionally one group per file; the dialog stays open. + private void SaveBlueprints_Click(object sender, EventArgs e) + { + List rows = SelectedRows().Where(r => r.Multi != null).ToList(); + if (rows.Count == 0) + { + return; + } + + var fileOf = new Dictionary(); + var sources = new List(); + foreach (Row row in rows) + { + BlueprintSource source = DocumentIO.BlueprintFromImport(row.Multi); + sources.Add(source); + fileOf[source] = Path.GetFileNameWithoutExtension(row.File); + } + + if (BlueprintSaveDialog.Run(this, sources, out string showGroup, "One group per file", s => fileOf[s]) is { } result) + { + _blueprintsSaved(showGroup); + _status.Text = BlueprintSaveDialog.Describe(result, showGroup); + } + } + private void PreviewTimer_Tick(object sender, EventArgs e) { _previewTimer.Stop(); @@ -531,7 +568,7 @@ private void UpdateSelection() List rows = SelectedRows(); int openable = rows.Count(r => r.Multi != null); - _ok.Enabled = openable > 0; + _ok.Enabled = _saveBlueprints.Enabled = openable > 0; _ok.Text = openable > 1 ? $"Open {openable:N0}" : "Open"; if (rows.Count > 1) diff --git a/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs index deb6ac3d..ee97834f 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs @@ -319,7 +319,7 @@ private void EnsureBuilt() Session.SwapSelectionToHouseStyle(); } }; - _browser.PrefabChosen += tiles => + _browser.BlueprintChosen += tiles => { Session?.BeginPaste(tiles); _canvas.Focus(); @@ -443,7 +443,7 @@ private EditorCommand Command(string group, string text, Action execute, Keys sh }); } - private EditorCommand _new, _open, _import, _importFolder, _saveClient, _export, _savePrefab, _resize, _close, _closeAll; + private EditorCommand _new, _open, _import, _importFolder, _saveClient, _export, _saveBlueprint, _resize, _close, _closeAll; private EditorCommand _undo, _redo, _cut, _copy, _paste, _delete; private EditorCommand _selectAll, _invert, _selectConnected, _selectSameId, _replaceId; private EditorCommand _rotateLeft, _rotateRight, _mirrorX, _mirrorY, _swapStyle, _hide, _showHidden; @@ -478,7 +478,7 @@ private void DefineCommands() _importFolder = Command("File", "Import from folder...", ImportFolder, Keys.Control | Keys.Shift | Keys.I); _saveClient = Command("File", "Save to client...", SaveToClient, Keys.Control | Keys.S, hasDoc, glyph: Glyphs.Save); _export = Command("File", "Export file...", ExportFile, Keys.Control | Keys.E, hasDoc); - _savePrefab = Command("File", "Save selection as blueprint...", SavePrefab, can: hasSelection); + _saveBlueprint = Command("File", "Save as blueprint...", SaveBlueprint, can: hasDoc); _savePattern = Command("File", "Save selection as floor pattern...", SaveFloorPattern, can: hasSelection); _resize = Command("File", "Resize multi...", ResizePrompt, can: hasDoc); _settingsCommand = Command("File", "Settings...", ShowSettings); @@ -711,16 +711,30 @@ private void ReplaceIdPrompt() _statusHint.Text = $"Replaced {count} tile(s)" + (selectionOnly ? " in the selection" : string.Empty); } - private void SavePrefab() + /// Saves the selection as a blueprint, or the whole design when nothing is selected. + private void SaveBlueprint() { - List tiles = Session.SelectionAsClipboard(); - string name = tiles != null ? Prompt.Text(this, "Save blueprint", "Blueprint name:") : null; - if (name == null) + bool whole = Session.Selection.Count == 0; + List tiles = whole ? Session.DesignAsClipboard() : Session.SelectionAsClipboard(); + if (tiles == null) + { + return; + } + + if (tiles.Count > Blueprints.LargeBlueprintTiles && + MessageBox.Show(this, $"This blueprint has {tiles.Count:N0} tiles. Large blueprints are slow to place and to preview.\r\nSave it anyway?", + "Save blueprint", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK) + { + return; + } + + string name = Prompt.Text(this, whole ? "Save design as blueprint" : "Save blueprint", "Blueprint name:", whole ? Session.Title : string.Empty); + if (string.IsNullOrWhiteSpace(name)) { return; } - _browser.SaveBlueprint(name, tiles); + _browser.SaveBlueprint(name.Trim(), tiles); } private void DrawWithSelected() @@ -755,7 +769,7 @@ private void BuildCanvasMenu() } _canvasMenu.Items.Add(new ToolStripSeparator()); - foreach (EditorCommand command in new[] { _selectConnected, _selectSameId, _selectAll, _savePrefab, _savePattern }) + foreach (EditorCommand command in new[] { _selectConnected, _selectSameId, _selectAll, _saveBlueprint, _savePattern }) { _canvasMenu.Items.Add(_commands.MenuItem(command)); } @@ -1133,7 +1147,7 @@ private void BuildMainStrip() { ToolStrip strip = _mainStrip; - Menu(_fileMenu, _new, _open, _import, _importFolder, null, _saveClient, _export, _savePrefab, _savePattern, null, _resize, _settingsCommand, null, _close, _closeAll); + Menu(_fileMenu, _new, _open, _import, _importFolder, null, _saveClient, _export, _saveBlueprint, _savePattern, null, _resize, _settingsCommand, null, _close, _closeAll); Menu(_editMenu, _undo, _redo, null, _cut, _copy, _paste, _delete, null, _rotateLeft, _rotateRight, _mirrorX, _mirrorY, _swapStyle, null, _zUp, _zDown, _hide, _showHidden); Menu(_selectMenu, _selectAll, _invert, _selectConnected, _selectSameId, null, _replaceId); @@ -1393,17 +1407,23 @@ private void NewDocument(int width, int height, string title, int firstFloorZ = private void OpenFromClient() { - AddDocuments(DocumentIO.OpenFromClient(this)); + AddDocuments(DocumentIO.OpenFromClient(this, BlueprintsSaved)); } private void ImportFile() { - AddImported(DocumentIO.ImportFromFile(this)); + AddImported(DocumentIO.ImportFromFile(this, BlueprintsSaved)); } private void ImportFolder() { - AddImported(DocumentIO.ImportFromFolder(this)); + AddImported(DocumentIO.ImportFromFolder(this, BlueprintsSaved)); + } + + /// Blueprints were written by an open or import dialog: the Blueprints page shows the group (null for all). + private void BlueprintsSaved(string showGroup) + { + _browser.ReloadBlueprints(showGroup); } /// Opens what an import read, then lists what it could not. diff --git a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.Designer.cs index c067bf5f..9fc20f2a 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.Designer.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.Designer.cs @@ -59,16 +59,16 @@ private void InitializeComponent() _houseKindBar = new System.Windows.Forms.ToolStrip(); _foundationButton = new System.Windows.Forms.ToolStripButton(); _patternsButton = new System.Windows.Forms.ToolStripButton(); - _prefabsPage = new System.Windows.Forms.TabPage(); - _prefabList = new System.Windows.Forms.ListView(); - _prefabIcons = new System.Windows.Forms.ImageList(components); - _prefabGroupBar = new System.Windows.Forms.ToolStrip(); + _blueprintsPage = new System.Windows.Forms.TabPage(); + _blueprintList = new System.Windows.Forms.ListView(); + _blueprintIcons = new System.Windows.Forms.ImageList(components); + _blueprintGroupBar = new System.Windows.Forms.ToolStrip(); _designsPage = new System.Windows.Forms.TabPage(); _designLibrary = new DesignLibraryPanel(); _toolTip = new System.Windows.Forms.ToolTip(components); _searchTimer = new System.Windows.Forms.Timer(components); _tileMenu = new System.Windows.Forms.ContextMenuStrip(components); - _prefabMenu = new System.Windows.Forms.ContextMenuStrip(components); + _blueprintMenu = new System.Windows.Forms.ContextMenuStrip(components); _filterBar.SuspendLayout(); _tabs.SuspendLayout(); _tilesPage.SuspendLayout(); @@ -81,7 +81,7 @@ private void InitializeComponent() _recentBar.SuspendLayout(); _housePage.SuspendLayout(); _houseKindBar.SuspendLayout(); - _prefabsPage.SuspendLayout(); + _blueprintsPage.SuspendLayout(); _designsPage.SuspendLayout(); SuspendLayout(); // @@ -144,7 +144,7 @@ private void InitializeComponent() _tabs.Controls.Add(_favoritesPage); _tabs.Controls.Add(_recentPage); _tabs.Controls.Add(_housePage); - _tabs.Controls.Add(_prefabsPage); + _tabs.Controls.Add(_blueprintsPage); _tabs.Controls.Add(_designsPage); _tabs.Dock = System.Windows.Forms.DockStyle.Fill; _tabs.Location = new System.Drawing.Point(0, 52); @@ -387,44 +387,44 @@ private void InitializeComponent() _patternsButton.ToolTipText = "Bordered floors: corner, side and fill pieces laid as one. Save your own from a selection (File menu)."; _patternsButton.Click += PatternsButton_Click; // - // _prefabsPage - // - _prefabsPage.Controls.Add(_prefabList); - _prefabsPage.Controls.Add(_prefabGroupBar); - _prefabsPage.Location = new System.Drawing.Point(4, 44); - _prefabsPage.Name = "_prefabsPage"; - _prefabsPage.Size = new System.Drawing.Size(192, 52); - _prefabsPage.TabIndex = 4; - _prefabsPage.Text = "Blueprints"; - // - // _prefabList - // - _prefabList.Dock = System.Windows.Forms.DockStyle.Fill; - _prefabList.LargeImageList = _prefabIcons; - _prefabList.Location = new System.Drawing.Point(0, 0); - _prefabList.MultiSelect = false; - _prefabList.Name = "_prefabList"; - _prefabList.ShowItemToolTips = true; - _prefabList.Size = new System.Drawing.Size(192, 52); - _prefabList.TabIndex = 0; - _prefabList.UseCompatibleStateImageBehavior = false; - _prefabList.KeyDown += PrefabList_KeyDown; - _prefabList.MouseClick += PrefabList_MouseClick; - // - // _prefabIcons - // - _prefabIcons.ColorDepth = System.Windows.Forms.ColorDepth.Depth32Bit; - _prefabIcons.ImageSize = new System.Drawing.Size(80, 80); - _prefabIcons.TransparentColor = System.Drawing.Color.Transparent; - // - // _prefabGroupBar - // - _prefabGroupBar.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden; - _prefabGroupBar.LayoutStyle = System.Windows.Forms.ToolStripLayoutStyle.Flow; - _prefabGroupBar.Location = new System.Drawing.Point(0, 0); - _prefabGroupBar.Name = "_prefabGroupBar"; - _prefabGroupBar.Size = new System.Drawing.Size(192, 0); - _prefabGroupBar.TabIndex = 1; + // _blueprintsPage + // + _blueprintsPage.Controls.Add(_blueprintList); + _blueprintsPage.Controls.Add(_blueprintGroupBar); + _blueprintsPage.Location = new System.Drawing.Point(4, 44); + _blueprintsPage.Name = "_blueprintsPage"; + _blueprintsPage.Size = new System.Drawing.Size(192, 52); + _blueprintsPage.TabIndex = 4; + _blueprintsPage.Text = "Blueprints"; + // + // _blueprintList + // + _blueprintList.Dock = System.Windows.Forms.DockStyle.Fill; + _blueprintList.LargeImageList = _blueprintIcons; + _blueprintList.Location = new System.Drawing.Point(0, 0); + _blueprintList.MultiSelect = false; + _blueprintList.Name = "_blueprintList"; + _blueprintList.ShowItemToolTips = true; + _blueprintList.Size = new System.Drawing.Size(192, 52); + _blueprintList.TabIndex = 0; + _blueprintList.UseCompatibleStateImageBehavior = false; + _blueprintList.KeyDown += BlueprintList_KeyDown; + _blueprintList.MouseClick += BlueprintList_MouseClick; + // + // _blueprintIcons + // + _blueprintIcons.ColorDepth = System.Windows.Forms.ColorDepth.Depth32Bit; + _blueprintIcons.ImageSize = new System.Drawing.Size(80, 80); + _blueprintIcons.TransparentColor = System.Drawing.Color.Transparent; + // + // _blueprintGroupBar + // + _blueprintGroupBar.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden; + _blueprintGroupBar.LayoutStyle = System.Windows.Forms.ToolStripLayoutStyle.Flow; + _blueprintGroupBar.Location = new System.Drawing.Point(0, 0); + _blueprintGroupBar.Name = "_blueprintGroupBar"; + _blueprintGroupBar.Size = new System.Drawing.Size(192, 0); + _blueprintGroupBar.TabIndex = 1; // // _designsPage // @@ -444,6 +444,7 @@ private void InitializeComponent() _designLibrary.TabIndex = 0; _designLibrary.OpenRequested += DesignLibrary_OpenRequested; _designLibrary.PlaceRequested += DesignLibrary_PlaceRequested; + _designLibrary.BlueprintsSaved += DesignLibrary_BlueprintsSaved; // // _searchTimer // @@ -455,10 +456,10 @@ private void InitializeComponent() _tileMenu.Name = "_tileMenu"; _tileMenu.Size = new System.Drawing.Size(61, 4); // - // _prefabMenu + // _blueprintMenu // - _prefabMenu.Name = "_prefabMenu"; - _prefabMenu.Size = new System.Drawing.Size(61, 4); + _blueprintMenu.Name = "_blueprintMenu"; + _blueprintMenu.Size = new System.Drawing.Size(61, 4); // // TileBrowserPanel // @@ -486,8 +487,8 @@ private void InitializeComponent() _housePage.PerformLayout(); _houseKindBar.ResumeLayout(false); _houseKindBar.PerformLayout(); - _prefabsPage.ResumeLayout(false); - _prefabsPage.PerformLayout(); + _blueprintsPage.ResumeLayout(false); + _blueprintsPage.PerformLayout(); _designsPage.ResumeLayout(false); ResumeLayout(false); PerformLayout(); @@ -525,15 +526,15 @@ private void InitializeComponent() private System.Windows.Forms.ToolStrip _houseKindBar; private System.Windows.Forms.ToolStripButton _foundationButton; private System.Windows.Forms.ToolStripButton _patternsButton; - private System.Windows.Forms.TabPage _prefabsPage; - private System.Windows.Forms.ListView _prefabList; - private System.Windows.Forms.ImageList _prefabIcons; - private System.Windows.Forms.ToolStrip _prefabGroupBar; + private System.Windows.Forms.TabPage _blueprintsPage; + private System.Windows.Forms.ListView _blueprintList; + private System.Windows.Forms.ImageList _blueprintIcons; + private System.Windows.Forms.ToolStrip _blueprintGroupBar; private System.Windows.Forms.TabPage _designsPage; private DesignLibraryPanel _designLibrary; private System.Windows.Forms.ToolTip _toolTip; private System.Windows.Forms.Timer _searchTimer; private System.Windows.Forms.ContextMenuStrip _tileMenu; - private System.Windows.Forms.ContextMenuStrip _prefabMenu; + private System.Windows.Forms.ContextMenuStrip _blueprintMenu; } } diff --git a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.cs b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.cs index 55a92787..c81ebcfc 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.cs @@ -24,7 +24,7 @@ namespace UoFiddler.Plugin.MultiEditor.UI { /// /// The editor's tile picker: one search box over pages of tiles (the multieditor.xml tree), - /// favorites, recently placed tiles, house part styles and prefabs. Data files are read on + /// favorites, recently placed tiles, house part styles and blueprints. Data files are read on /// first show, and a missing file only leaves its page empty. /// internal sealed partial class TileBrowserPanel : UserControl @@ -78,7 +78,7 @@ private static readonly (string Name, Func Matches)[] _tileTypes private int _favoritesVersion = -1; private int _recentVersion = -1; private int _houseVersion = -1; - private int _prefabsVersion = -1; + private int _blueprintsVersion = -1; /// /// House styles are not passed here: they arrive through , @@ -140,8 +140,8 @@ public TileBrowserPanel() /// The user picked the foundation entry of the House page. public event Action FoundationChosen; - /// The user activated a prefab: the shell starts a paste with these tiles. - public event Action> PrefabChosen; + /// The user activated a blueprint: the shell starts a paste with these tiles. + public event Action> BlueprintChosen; /// /// The House page's swap button: swap the selected pieces to the same columns of this style @@ -187,12 +187,12 @@ public void RecordRecent(ushort id) } } - /// Re-reads the prefab folder. - public void RefreshPrefabs() + /// Re-reads the blueprint folder. + public void RefreshBlueprints() { - if (_prefabsLoaded) + if (_blueprintsLoaded) { - LoadPrefabs(); + LoadBlueprints(); } } @@ -212,9 +212,9 @@ public void ReloadData() _categoryBeforeSearch = FindCategory(beforeSearchPath); ReloadHouseIcons(); - if (_prefabsLoaded) + if (_blueprintsLoaded) { - LoadPrefabs(); + LoadBlueprints(); } _filterVersion++; @@ -286,6 +286,12 @@ private void DesignLibrary_PlaceRequested(UoaDesign design) DesignPlaceRequested?.Invoke(design); } + /// Designs were saved as blueprints: the Blueprints page shows them next time, the Designs page stays up. + private void DesignLibrary_BlueprintsSaved(string showGroup) + { + ReloadBlueprints(showGroup, bringToFront: false); + } + private void TilesSplit_SizeChanged(object sender, EventArgs e) { PlaceSplitter(); @@ -452,9 +458,9 @@ private void SearchBox_KeyDown(object sender, KeyEventArgs e) { _houseGallery.Focus(); } - else if (_tabs.SelectedTab == _prefabsPage) + else if (_tabs.SelectedTab == _blueprintsPage) { - _prefabList.Focus(); + _blueprintList.Focus(); } e.Handled = true; @@ -478,10 +484,10 @@ private void PickFirstResult() { ActivateFirstHouseNode(); } - else if (_tabs.SelectedTab == _prefabsPage && _prefabList.Items.Count > 0) + else if (_tabs.SelectedTab == _blueprintsPage && _blueprintList.Items.Count > 0) { - _prefabList.Items[0].Selected = true; - PlaceSelectedPrefab(); + _blueprintList.Items[0].Selected = true; + PlaceSelectedBlueprint(); } } @@ -555,13 +561,15 @@ private void RefreshSelectedPage(bool keepScroll = false) RebuildHouseTree(); } } - else if (page == _prefabsPage) + else if (page == _blueprintsPage) { - EnsurePrefabs(); - if (_prefabsVersion != _filterVersion) + EnsureBlueprints(); + if (_blueprintsVersion != _filterVersion) { - RebuildPrefabList(); + RebuildBlueprintList(); } + + StartThumbnails(); } // Results start at the top; only a first fill looks for the current tile. @@ -600,7 +608,11 @@ private void UpdateStatus() } else { - list = _prefabList.Items.Count == 0 && _query == null ? "No blueprints saved yet" : $"{_prefabList.Items.Count} blueprints"; + list = _blueprintList.Items.Count == 0 && _query == null ? "No blueprints saved yet" : $"{_blueprintList.Items.Count} blueprints"; + if (SelectedBlueprint is { } blueprint) + { + list += $" | {blueprint.Name}: {Blueprints.FootprintText(blueprint.Footprint, true)}, {blueprint.TileCount:N0} tiles"; + } } string current = _currentTile >= 0 ? $"Current 0x{_currentTile:X4} {TileBrowserThumbnails.NameOf(_currentTile)}" : string.Empty; @@ -1657,25 +1669,55 @@ private void ActivateFirstHouseNode() // ---------------------------------------------------------------- blueprints page - private const int _prefabIconSize = 80; + private const int _blueprintIconSize = 80; - private readonly List _prefabs = new List(); - private readonly List _prefabGroups = new List(); - private bool _prefabsLoaded; + private readonly List _blueprints = new List(); + private readonly List _blueprintGroups = new List(); + private bool _blueprintsLoaded; /// The group shown: null for all, empty for the blueprints in no group, else a group name. - private string _prefabGroup; + private string _blueprintGroup; - /// A blueprint file. Group is the subfolder it is in, empty for the blueprint folder itself. - private sealed record PrefabEntry(string Name, string Group, string Path, int TileCount, int ImageIndex, HashSet Ids); + /// Blueprints whose thumbnail is not drawn yet; they show the blank image 0 meanwhile. + private readonly HashSet _thumbsPending = new HashSet(); + private Timer _thumbTimer; + + /// + /// A blueprint file. Group is the subfolder it is in, empty for the blueprint folder itself; + /// ImageIndex is 0 (blank) until its thumbnail is drawn. + /// + private sealed class BlueprintEntry + { + public BlueprintEntry(string name, string group, string path, int tileCount, (int Width, int Depth, int Height) footprint, + HashSet ids) + { + Name = name; + Group = group; + Path = path; + TileCount = tileCount; + Footprint = footprint; + Ids = ids; + } + + public string Name { get; } + public string Group { get; } + public string Path { get; } + public int TileCount { get; } + + /// Cells along X and Y and how high it is in Z. + public (int Width, int Depth, int Height) Footprint { get; } + + public HashSet Ids { get; } + public int ImageIndex { get; set; } + } /// /// One click places a blueprint, like picking a tile; a right click opens its menu /// without starting a paste. /// - private void PrefabList_MouseClick(object sender, MouseEventArgs e) + private void BlueprintList_MouseClick(object sender, MouseEventArgs e) { - if (_prefabList.GetItemAt(e.X, e.Y) is not { } item) + if (_blueprintList.GetItemAt(e.X, e.Y) is not { } item) { return; } @@ -1683,53 +1725,71 @@ private void PrefabList_MouseClick(object sender, MouseEventArgs e) item.Selected = true; if (e.Button == MouseButtons.Left) { - PlaceSelectedPrefab(); + PlaceSelectedBlueprint(); } else if (e.Button == MouseButtons.Right) { - ShowPrefabMenu(e.Location); + ShowBlueprintMenu(e.Location); } } - private void PrefabList_KeyDown(object sender, KeyEventArgs e) + private void BlueprintList_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Delete) { - DeleteSelectedPrefab(); + DeleteSelectedBlueprint(); e.Handled = true; } else if (e.KeyCode == Keys.Enter) { - PlaceSelectedPrefab(); + PlaceSelectedBlueprint(); e.Handled = e.SuppressKeyPress = true; } else if (e.KeyCode == Keys.F2) { - RenameSelectedPrefab(); + RenameSelectedBlueprint(); e.Handled = true; } } - private void EnsurePrefabs() + private void EnsureBlueprints() { - if (!_prefabsLoaded) + if (!_blueprintsLoaded) { - LoadPrefabs(); + LoadBlueprints(); } } + /// The group a new blueprint goes to: the one shown, empty (no group) when all are shown. + public string ShownBlueprintGroup => _blueprintGroup ?? string.Empty; + /// /// Saves tiles as a blueprint in the group being shown (no group when all are shown) and - /// brings up the Blueprints page. + /// brings up the Blueprints page. A name that is taken asks whether to replace it. /// public void SaveBlueprint(string name, List tiles) { EnsureLoaded(); + string folder = Blueprints.GroupFolder(ShownBlueprintGroup); + string path = Path.Combine(folder, Blueprints.SafeName(name) + ".txt"); + if (File.Exists(path)) + { + DialogResult answer = MessageBox.Show(this, $"A blueprint named \"{name}\" already exists. Replace it?\n\nNo saves this one under a new name.", + _messageTitle, MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question); + if (answer == DialogResult.Cancel) + { + return; + } + + if (answer == DialogResult.No) + { + (path, name) = Blueprints.UniqueName(folder, name); + } + } + try { - string folder = GroupFolder(_prefabGroup ?? string.Empty); - Directory.CreateDirectory(folder); - File.WriteAllText(Path.Combine(folder, Prefabs.SafeName(name) + ".txt"), ClipboardTile.Format(tiles)); + Blueprints.Write(path, tiles, name); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -1737,233 +1797,361 @@ public void SaveBlueprint(string name, List tiles) return; } - if (_tabs.SelectedTab != _prefabsPage) + ReloadBlueprints(_blueprintGroup); + } + + /// + /// Re-reads the blueprint folder and shows a group (null for all) on the Blueprints page, + /// after blueprints were written elsewhere; the page is brought up unless told not to. + /// + public void ReloadBlueprints(string showGroup, bool bringToFront = true) + { + EnsureLoaded(); + _blueprintGroup = showGroup; + + // A page never shown reads the folder when it first shows. + bool wasLoaded = _blueprintsLoaded; + if (bringToFront && _tabs.SelectedTab != _blueprintsPage) { - _tabs.SelectedTab = _prefabsPage; + _tabs.SelectedTab = _blueprintsPage; } - LoadPrefabs(); + if (wasLoaded) + { + LoadBlueprints(); + } } - private static string GroupFolder(string group) - { - return string.IsNullOrEmpty(group) ? Prefabs.Folder : Path.Combine(Prefabs.Folder, group); - } + private static string GroupFolder(string group) => Blueprints.GroupFolder(group); /// - /// Reads every blueprint file and draws its thumbnail; unreadable files are skipped. A - /// subfolder of the blueprint folder is a group, also when it is empty. + /// Reads every blueprint file; unreadable files are skipped. Thumbnails are drawn afterwards, + /// the ones in view first. A subfolder of the blueprint folder is a group, also when it is empty. /// - private void LoadPrefabs() + private void LoadBlueprints() { - _prefabsLoaded = true; - _prefabs.Clear(); - _prefabGroups.Clear(); - _prefabList.Items.Clear(); - _prefabIcons.Images.Clear(); + _blueprintsLoaded = true; + _blueprints.Clear(); + _thumbsPending.Clear(); + _blueprintGroups.Clear(); + _blueprintList.Items.Clear(); + _blueprintIcons.Images.Clear(); + using (var blank = new Bitmap(_blueprintIconSize, _blueprintIconSize)) + { + _blueprintIcons.Images.Add(blank); + } + bool complete = true; try { - if (Directory.Exists(Prefabs.Folder)) + if (Directory.Exists(Blueprints.Folder)) { - AddPrefabsOf(string.Empty); - foreach (string folder in Directory.EnumerateDirectories(Prefabs.Folder).OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) + AddBlueprintsOf(string.Empty); + foreach (string folder in Directory.EnumerateDirectories(Blueprints.Folder).OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) { string group = Path.GetFileName(folder); - _prefabGroups.Add(group); - AddPrefabsOf(group); + _blueprintGroups.Add(group); + AddBlueprintsOf(group); } } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // What could be read is shown. + complete = false; } - if (!string.IsNullOrEmpty(_prefabGroup) && !_prefabGroups.Contains(_prefabGroup)) + if (!string.IsNullOrEmpty(_blueprintGroup) && !_blueprintGroups.Contains(_blueprintGroup)) { - _prefabGroup = null; + _blueprintGroup = null; } - RebuildPrefabGroupBar(); - RebuildPrefabList(); + // Only a full read says a group is gone; a failed one may just have stopped early. + if (complete) + { + DropMissingFavoriteGroups(); + } + + RebuildBlueprintGroupBar(); + RebuildBlueprintList(); } - private void AddPrefabsOf(string group) + private void AddBlueprintsOf(string group) { foreach (string path in Directory.EnumerateFiles(GroupFolder(group), "*.txt").OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) { - List tiles = LoadPrefab(path); + (List tiles, string name) = Blueprints.Read(path); if (tiles == null || tiles.Count == 0) { continue; } - using (Bitmap thumb = Prefabs.Thumbnail(tiles, _prefabIconSize)) - { - _prefabIcons.Images.Add(thumb); - } - - _prefabs.Add(new PrefabEntry(Path.GetFileNameWithoutExtension(path), group, path, tiles.Count, - _prefabIcons.Images.Count - 1, tiles.Select(t => (int)t.Id).ToHashSet())); + var entry = new BlueprintEntry(name, group, path, tiles.Count, + Blueprints.Footprint(tiles), tiles.Select(t => (int)t.Id).ToHashSet()); + _blueprints.Add(entry); + _thumbsPending.Add(entry); } } - private static List LoadPrefab(string path) + private static List LoadBlueprint(string path) => Blueprints.Read(path).Tiles; + + /// Starts drawing the thumbnails still missing, while the Blueprints page shows. + private void StartThumbnails() { - try + if (_thumbsPending.Count == 0) { - return File.Exists(path) ? ClipboardTile.Parse(File.ReadAllText(path)) : null; + return; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + + if (_thumbTimer == null) { - return null; + _thumbTimer = new Timer(components) { Interval = 15 }; + _thumbTimer.Tick += ThumbTimer_Tick; } + + _thumbTimer.Start(); } - private void RebuildPrefabGroupBar() + /// + /// Draws missing thumbnails for a few milliseconds: the blueprints in view first, then the + /// rest of the list. Stops when the page is hidden or every listed one is drawn. + /// + private void ThumbTimer_Tick(object sender, EventArgs e) { - _prefabGroupBar.Items.Clear(); + if (!_blueprintList.Visible || _thumbsPending.Count == 0) + { + _thumbTimer.Stop(); + return; + } - void Add(string text, string group, string tip) + var watch = System.Diagnostics.Stopwatch.StartNew(); + Rectangle view = _blueprintList.ClientRectangle; + var inView = new List(); + var later = new List(); + foreach (ListViewItem item in _blueprintList.Items) { - int count = group == null ? _prefabs.Count : _prefabs.Count(p => p.Group == group); - var button = new ToolStripButton($"{text} ({count})") + if (item.Tag is BlueprintEntry entry && _thumbsPending.Contains(entry)) { - DisplayStyle = ToolStripItemDisplayStyle.Text, - Checked = _prefabGroup == group, - ToolTipText = tip, - AutoToolTip = false, - Margin = new Padding(1, 1, 0, 2) - }; - button.Click += (_, _) => + (item.Bounds.IntersectsWith(view) ? inView : later).Add(item); + } + } + + if (inView.Count == 0 && later.Count == 0) + { + // What is left is filtered out; it is drawn when it shows again. + _thumbTimer.Stop(); + return; + } + + // Setting an item's image repaints only that item, so no BeginUpdate (it would repaint the list). + foreach (ListViewItem item in inView.Concat(later)) + { + var entry = (BlueprintEntry)item.Tag; + _thumbsPending.Remove(entry); + if (LoadBlueprint(entry.Path) is { Count: > 0 } tiles) + { + using Bitmap thumb = Blueprints.Thumbnail(tiles, _blueprintIconSize); + _blueprintIcons.Images.Add(thumb); + entry.ImageIndex = _blueprintIcons.Images.Count - 1; + item.ImageIndex = entry.ImageIndex; + } + + if (watch.ElapsedMilliseconds > 30) + { + break; + } + } + } + + /// + /// The group bar: one drop-down to choose the group shown (a bulk save can make dozens of + /// groups, too many for a button each), which also renames or removes the group shown, + /// then New group and Refresh. + /// + private void RebuildBlueprintGroupBar() + { + _blueprintGroupBar.Items.Clear(); + + int CountOf(string group) => group == null ? _blueprints.Count : _blueprints.Count(p => p.Group == group); + static string Label(string group) => group == null ? "All" : group.Length == 0 ? "Ungrouped" : group; + + // A favorite shown is marked by its own button; the drop-down then just says what it is. + List favorites = FavoriteGroups(); + bool favoriteShown = favorites.Contains(_blueprintGroup); + var chooser = new ToolStripDropDownButton(favoriteShown ? "Groups" : $"{Label(_blueprintGroup)} ({CountOf(_blueprintGroup)})") + { + DisplayStyle = ToolStripItemDisplayStyle.Text, + ToolTipText = "The group shown", + AutoToolTip = false + }; + + void Add(string group) + { + var item = new ToolStripMenuItem($"{Label(group)} ({CountOf(group)})") { Checked = _blueprintGroup == group }; + item.Click += (_, _) => { - _prefabGroup = group; - RebuildPrefabGroupBar(); - RebuildPrefabList(); + _blueprintGroup = group; + RebuildBlueprintGroupBar(); + RebuildBlueprintList(); }; - if (!string.IsNullOrEmpty(group)) + chooser.DropDownItems.Add(item); + } + + Add(null); + Add(string.Empty); + if (_blueprintGroups.Count > 0) + { + chooser.DropDownItems.Add(new ToolStripSeparator()); + foreach (string group in _blueprintGroups) { - // A named group can be renamed or removed from its own button. - button.MouseUp += (_, e) => - { - if (e.Button == MouseButtons.Right) - { - ShowGroupMenu(group, _prefabGroupBar.PointToClient(Cursor.Position)); - } - }; + Add(group); } + } - _prefabGroupBar.Items.Add(button); + if (!string.IsNullOrEmpty(_blueprintGroup)) + { + string shown = _blueprintGroup; + bool favorite = favorites.Contains(shown, StringComparer.OrdinalIgnoreCase); + chooser.DropDownItems.Add(new ToolStripSeparator()); + chooser.DropDownItems.Add(favorite ? $"Remove \"{shown}\" from favorites" : $"Add \"{shown}\" to favorites", null, + (_, _) => SetFavoriteGroup(shown, !favorite)); + chooser.DropDownItems.Add($"Rename \"{shown}\"...", null, (_, _) => RenameBlueprintGroup(shown)); + chooser.DropDownItems.Add($"Remove \"{shown}\" (its blueprints become ungrouped)", null, (_, _) => RemoveBlueprintGroup(shown)); } - Add("All", null, "Every blueprint"); - Add("Ungrouped", string.Empty, "Blueprints in no group"); - foreach (string group in _prefabGroups) + _blueprintGroupBar.Items.Add(chooser); + + // Favorite groups stay on the bar, one click away; right click takes one off. + foreach (string group in favorites) { - Add(group, group, "Right click to rename or remove this group"); + string favorite = group; + var button = new ToolStripButton("★ " + favorite) + { + DisplayStyle = ToolStripItemDisplayStyle.Text, + Checked = _blueprintGroup == favorite, + ToolTipText = $"{favorite}: {CountOf(favorite)} blueprints. Right click to remove it from favorites.", + AutoToolTip = false + }; + button.Click += (_, _) => + { + _blueprintGroup = favorite; + RebuildBlueprintGroupBar(); + RebuildBlueprintList(); + }; + button.MouseUp += (_, e) => + { + if (e.Button == MouseButtons.Right) + { + _blueprintMenu.Items.Clear(); + _blueprintMenu.Items.Add($"Remove \"{favorite}\" from favorites", null, (_, _) => SetFavoriteGroup(favorite, false)); + _blueprintMenu.Show(_blueprintGroupBar, _blueprintGroupBar.PointToClient(Cursor.Position)); + } + }; + _blueprintGroupBar.Items.Add(button); } var create = new ToolStripButton("+ Group") { DisplayStyle = ToolStripItemDisplayStyle.Text, ToolTipText = "Make a new group", AutoToolTip = false }; - create.Click += (_, _) => CreatePrefabGroup(null); - _prefabGroupBar.Items.Add(new ToolStripSeparator()); - _prefabGroupBar.Items.Add(create); + create.Click += (_, _) => CreateBlueprintGroup(null); + _blueprintGroupBar.Items.Add(new ToolStripSeparator()); + _blueprintGroupBar.Items.Add(create); var refresh = new ToolStripButton("Refresh") { DisplayStyle = ToolStripItemDisplayStyle.Text, ToolTipText = "Read the blueprint folder again", AutoToolTip = false }; - refresh.Click += (_, _) => LoadPrefabs(); - _prefabGroupBar.Items.Add(refresh); + refresh.Click += (_, _) => LoadBlueprints(); + _blueprintGroupBar.Items.Add(refresh); } /// Shows the blueprints of the chosen group whose name matches the search, or that hold a searched id. - private void RebuildPrefabList() + private void RebuildBlueprintList() { TileBrowserQuery query = _query; - string selected = (_prefabList.SelectedItems.Count > 0 ? _prefabList.SelectedItems[0].Tag as PrefabEntry : null)?.Path; - _prefabList.BeginUpdate(); + string selected = (_blueprintList.SelectedItems.Count > 0 ? _blueprintList.SelectedItems[0].Tag as BlueprintEntry : null)?.Path; + _blueprintList.BeginUpdate(); try { - _prefabList.Items.Clear(); - foreach (PrefabEntry prefab in _prefabs) + _blueprintList.Items.Clear(); + foreach (BlueprintEntry blueprint in _blueprints) { - if (_prefabGroup != null && prefab.Group != _prefabGroup) + if (_blueprintGroup != null && blueprint.Group != _blueprintGroup) { continue; } - if (query != null && !(query.IsIdQuery ? prefab.Ids.Any(query.MatchesId) : query.MatchesText(prefab.Group + " " + prefab.Name))) + if (query != null && !(query.IsIdQuery ? blueprint.Ids.Any(query.MatchesId) : query.MatchesText(blueprint.Group + " " + blueprint.Name))) { continue; } - string where = prefab.Group.Length > 0 ? $" (group {prefab.Group})" : string.Empty; - var item = new ListViewItem(prefab.Name, prefab.ImageIndex) + // Thumbnails are all scaled to one size, so the label says how big it really is. + string where = blueprint.Group.Length > 0 ? $"\ngroup {blueprint.Group}" : string.Empty; + var item = new ListViewItem($"{blueprint.Name} ({Blueprints.FootprintText(blueprint.Footprint, false)})", blueprint.ImageIndex) { - Tag = prefab, - ToolTipText = $"{prefab.Name}: {prefab.TileCount} tiles{where}" + Tag = blueprint, + ToolTipText = $"{blueprint.Name}\n{Blueprints.FootprintText(blueprint.Footprint, true)}, {blueprint.TileCount:N0} tiles{where}" }; - _prefabList.Items.Add(item); - item.Selected = prefab.Path == selected; + _blueprintList.Items.Add(item); + item.Selected = blueprint.Path == selected; } } finally { - _prefabList.EndUpdate(); + _blueprintList.EndUpdate(); } - _prefabsVersion = _filterVersion; + StartThumbnails(); + _blueprintsVersion = _filterVersion; UpdateStatus(); } - private PrefabEntry SelectedPrefab => _prefabList.SelectedItems.Count > 0 ? _prefabList.SelectedItems[0].Tag as PrefabEntry : null; + private BlueprintEntry SelectedBlueprint => _blueprintList.SelectedItems.Count > 0 ? _blueprintList.SelectedItems[0].Tag as BlueprintEntry : null; - private void PlaceSelectedPrefab() + private void PlaceSelectedBlueprint() { - if (SelectedPrefab is not { } prefab) + if (SelectedBlueprint is not { } blueprint) { return; } // Read again so the shell gets its own list and sees the file as it is now. - List tiles = LoadPrefab(prefab.Path); + List tiles = LoadBlueprint(blueprint.Path); if (tiles != null && tiles.Count > 0) { - PrefabChosen?.Invoke(tiles); + BlueprintChosen?.Invoke(tiles); } } - private void ShowPrefabMenu(Point location) + private void ShowBlueprintMenu(Point location) { - if (SelectedPrefab is not { } prefab) + if (SelectedBlueprint is not { } blueprint) { return; } - _prefabMenu.Items.Clear(); - _prefabMenu.Items.Add("Place", null, (_, _) => PlaceSelectedPrefab()); + _blueprintMenu.Items.Clear(); + _blueprintMenu.Items.Add("Place", null, (_, _) => PlaceSelectedBlueprint()); var move = new ToolStripMenuItem("Move to group"); void Target(string text, string group) { - var item = new ToolStripMenuItem(text) { Checked = prefab.Group == group, Enabled = prefab.Group != group }; - item.Click += (_, _) => MovePrefab(prefab, group); + var item = new ToolStripMenuItem(text) { Checked = blueprint.Group == group, Enabled = blueprint.Group != group }; + item.Click += (_, _) => MoveBlueprint(blueprint, group); move.DropDownItems.Add(item); } Target("Ungrouped", string.Empty); - foreach (string group in _prefabGroups) + foreach (string group in _blueprintGroups) { Target(group, group); } move.DropDownItems.Add(new ToolStripSeparator()); - move.DropDownItems.Add("New group...", null, (_, _) => CreatePrefabGroup(prefab)); - _prefabMenu.Items.Add(move); - _prefabMenu.Items.Add("Rename...", null, (_, _) => RenameSelectedPrefab()); - _prefabMenu.Items.Add("Delete", null, (_, _) => DeleteSelectedPrefab()); - _prefabMenu.Show(_prefabList, location); + move.DropDownItems.Add("New group...", null, (_, _) => CreateBlueprintGroup(blueprint)); + _blueprintMenu.Items.Add(move); + _blueprintMenu.Items.Add("Rename...", null, (_, _) => RenameSelectedBlueprint()); + _blueprintMenu.Items.Add("Delete", null, (_, _) => DeleteSelectedBlueprint()); + _blueprintMenu.Show(_blueprintList, location); } /// Runs a file operation on the blueprint folder, shows why when it fails, and re-reads the folder. - private void PrefabFileAction(string what, Action action) + private void BlueprintFileAction(string what, Action action) { try { @@ -1974,29 +2162,29 @@ private void PrefabFileAction(string what, Action action) MessageBox.Show(this, $"Could not {what}: {ex.Message}", _messageTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning); } - LoadPrefabs(); + LoadBlueprints(); } - private void MovePrefab(PrefabEntry prefab, string group) + private void MoveBlueprint(BlueprintEntry blueprint, string group) { - PrefabFileAction("move the blueprint", () => + BlueprintFileAction("move the blueprint", () => { string folder = GroupFolder(group); Directory.CreateDirectory(folder); - File.Move(prefab.Path, Path.Combine(folder, Path.GetFileName(prefab.Path))); + File.Move(blueprint.Path, Path.Combine(folder, Path.GetFileName(blueprint.Path))); }); } /// Asks for a group name and makes it; with a blueprint, moves that one into it. - private void CreatePrefabGroup(PrefabEntry moveIn) + private void CreateBlueprintGroup(BlueprintEntry moveIn) { - if (Prompt.Text(this, "New blueprint group", "Group name:") is not { } typed || Prefabs.SafeName(typed).Length == 0) + if (Prompt.Text(this, "New blueprint group", "Group name:") is not { } typed || Blueprints.SafeName(typed).Length == 0) { return; } - string group = Prefabs.SafeName(typed); - PrefabFileAction("make the group", () => + string group = Blueprints.SafeName(typed); + BlueprintFileAction("make the group", () => { Directory.CreateDirectory(GroupFolder(group)); if (moveIn != null) @@ -2005,67 +2193,150 @@ private void CreatePrefabGroup(PrefabEntry moveIn) } }); - _prefabGroup = _prefabGroups.Contains(group) ? group : _prefabGroup; - RebuildPrefabGroupBar(); - RebuildPrefabList(); + _blueprintGroup = _blueprintGroups.Contains(group) ? group : _blueprintGroup; + RebuildBlueprintGroupBar(); + RebuildBlueprintList(); } - private void ShowGroupMenu(string group, Point location) + /// + /// The favorite groups that still exist, by their folder's own spelling, in the order they + /// were added. + /// + private List FavoriteGroups() { - _prefabMenu.Items.Clear(); - _prefabMenu.Items.Add("Rename group...", null, (_, _) => + var favorites = new List(); + foreach (string saved in EditorSettings.Current.FavoriteBlueprintGroups.Split('|', StringSplitOptions.RemoveEmptyEntries)) { - if (Prompt.Text(this, "Rename blueprint group", "Group name:", group) is { } typed && - Prefabs.SafeName(typed) is { Length: > 0 } name && name != group) + string group = _blueprintGroups.Find(g => string.Equals(g, saved, StringComparison.OrdinalIgnoreCase)); + if (group != null && !favorites.Contains(group, StringComparer.OrdinalIgnoreCase)) { - if (_prefabGroup == group) - { - _prefabGroup = name; - } - - PrefabFileAction("rename the group", () => Directory.Move(GroupFolder(group), GroupFolder(name))); + favorites.Add(group); } + } + + return favorites; + } + + private static void SaveFavoriteGroups(IEnumerable groups) + { + string text = string.Join('|', groups.Distinct(StringComparer.OrdinalIgnoreCase)); + if (text != EditorSettings.Current.FavoriteBlueprintGroups) + { + EditorSettings settings = EditorSettings.Current.Clone(); + settings.FavoriteBlueprintGroups = text; + settings.Apply(); + } + } + + /// Adds a group to the favorites or takes it off, and redraws the bar. + private void SetFavoriteGroup(string group, bool favorite) + { + List favorites = FavoriteGroups(); + favorites.RemoveAll(g => string.Equals(g, group, StringComparison.OrdinalIgnoreCase)); + if (favorite) + { + favorites.Add(group); + } + + SaveFavoriteGroups(favorites); + RebuildBlueprintGroupBar(); + } + + /// + /// Forgets favorites whose folder is gone (deleted or renamed outside the editor), after the + /// blueprint folder was read. + /// + private void DropMissingFavoriteGroups() + { + SaveFavoriteGroups(FavoriteGroups()); + } + + private void RenameBlueprintGroup(string group) + { + if (Prompt.Text(this, "Rename blueprint group", "Group name:", group) is not { } typed || + Blueprints.SafeName(typed) is not { Length: > 0 } name || name == group) + { + return; + } + + if (_blueprintGroup == group) + { + _blueprintGroup = name; + } + + // A favorite keeps its place under the new name, once the folder has moved. + List favorites = FavoriteGroups().ConvertAll(g => string.Equals(g, group, StringComparison.OrdinalIgnoreCase) ? name : g); + BlueprintFileAction("rename the group", () => + { + Directory.Move(GroupFolder(group), GroupFolder(name)); + SaveFavoriteGroups(favorites); }); - _prefabMenu.Items.Add("Remove group (its blueprints become ungrouped)", null, (_, _) => + } + + /// Moves a group's blueprints to no group and removes the group's folder once it is empty. + private void RemoveBlueprintGroup(string group) + { + if (_blueprintGroup == group) { - PrefabFileAction("remove the group", () => + _blueprintGroup = string.Empty; + } + + BlueprintFileAction("remove the group", () => + { + foreach (string path in Directory.EnumerateFiles(GroupFolder(group), "*.txt").ToList()) { - foreach (string path in Directory.EnumerateFiles(GroupFolder(group), "*.txt").ToList()) - { - File.Move(path, Path.Combine(Prefabs.Folder, Path.GetFileName(path))); - } + // A name already taken in no group gets " (2)"..., so nothing is overwritten or left behind. + (string target, _) = Blueprints.UniqueName(Blueprints.Folder, Path.GetFileNameWithoutExtension(path)); + File.Move(path, target); + } - // Only an empty folder goes: anything else in it is not ours to delete. - if (!Directory.EnumerateFileSystemEntries(GroupFolder(group)).Any()) - { - Directory.Delete(GroupFolder(group)); - } - }); + // Only an empty folder goes: anything else in it is not ours to delete. + if (!Directory.EnumerateFileSystemEntries(GroupFolder(group)).Any()) + { + Directory.Delete(GroupFolder(group)); + } }); - _prefabMenu.Show(_prefabGroupBar, location); } - private void RenameSelectedPrefab() + /// + /// Renames a blueprint: the name line in the file and the file itself, which gets " (2)"... + /// when another blueprint in the group has that file name. + /// + private void RenameSelectedBlueprint() { - if (SelectedPrefab is not { } prefab || - Prompt.Text(this, "Rename blueprint", "Blueprint name:", prefab.Name) is not { } typed || - Prefabs.SafeName(typed) is not { Length: > 0 } name || name == prefab.Name) + if (SelectedBlueprint is not { } blueprint || + Prompt.Text(this, "Rename blueprint", "Blueprint name:", blueprint.Name) is not { } typed || + typed.Trim() is not { Length: > 0 } wanted || wanted == blueprint.Name || Blueprints.SafeName(wanted).Length == 0) { return; } - PrefabFileAction("rename the blueprint", () => File.Move(prefab.Path, Path.Combine(Path.GetDirectoryName(prefab.Path), name + ".txt"))); + BlueprintFileAction("rename the blueprint", () => + { + (List tiles, _) = Blueprints.Read(blueprint.Path); + if (tiles == null) + { + throw new IOException("the file could not be read."); + } + + (string path, string name) = Blueprints.UniqueName(Path.GetDirectoryName(blueprint.Path), wanted, own: blueprint.Path); + Blueprints.Write(path, tiles, name); + if (!string.Equals(Path.GetFullPath(path), Path.GetFullPath(blueprint.Path), StringComparison.OrdinalIgnoreCase)) + { + File.Delete(blueprint.Path); + } + }); } - private void DeleteSelectedPrefab() + private void DeleteSelectedBlueprint() { - if (SelectedPrefab is not { } prefab || - MessageBox.Show(this, $"Delete the blueprint \"{prefab.Name}\"?", _messageTitle, MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK) + if (SelectedBlueprint is not { } blueprint || + MessageBox.Show(this, $"Delete the blueprint \"{blueprint.Name}\"?", _messageTitle, MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK) { return; } - PrefabFileAction("delete the blueprint", () => File.Delete(prefab.Path)); + BlueprintFileAction("delete the blueprint", () => File.Delete(blueprint.Path)); } } } diff --git a/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj b/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj index a4a02bae..0484dc45 100644 --- a/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj +++ b/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj @@ -41,6 +41,12 @@ + + Form + + + BlueprintSaveDialog.cs + UserControl @@ -127,6 +133,9 @@ + + BlueprintSaveDialog.cs + DesignLibraryPanel.cs From 6749d086c299ba862f451a9e351c3f84d1a40dfe Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:43:38 +0100 Subject: [PATCH 2/5] Import and export CentrED# blueprints Reads CentrED# CSV blueprints and TilesEntry XML (one multi per entry); both are detected on import. --- Ultima/MultiComponentList.cs | 130 ++++++++++++++++++ Ultima/Multis.cs | 4 +- UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs | 10 +- .../UI/MultiFileImport.cs | 55 +++++++- 4 files changed, 194 insertions(+), 5 deletions(-) diff --git a/Ultima/MultiComponentList.cs b/Ultima/MultiComponentList.cs index 1d072142..a549cc7e 100644 --- a/Ultima/MultiComponentList.cs +++ b/Ultima/MultiComponentList.cs @@ -12,7 +12,9 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.Globalization; using System.IO; +using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Xml; @@ -649,6 +651,13 @@ public MultiComponentList(string fileName, Multis.ImportType type) case Multis.ImportType.MULTIFORGE: parsed = ReadMultiforge(fileName); break; + case Multis.ImportType.CENTREDCSV: + parsed = ReadCentredCsv(fileName); + break; + case Multis.ImportType.TILESENTRY: + // Every entry in one; ReadTilesEntries keeps them apart. + parsed = ReadTilesEntries(fileName).SelectMany(e => e.Tiles).ToList(); + break; } if (parsed != null) @@ -774,6 +783,72 @@ private static List ReadXml(string fileName) return list; } + /// + /// A CentrED# blueprint: an "id,x,y,z,hue,flags" header, then one tile per line with the id in + /// decimal or 0x hex and X/Y around the blueprint's centre. CentrED# writes the flags as 0 and + /// never reads them, so every tile is visible; multis have no hue, so it is dropped. + /// + private static List ReadCentredCsv(string fileName) + { + var list = new List(); + foreach (string raw in File.ReadLines(fileName)) + { + string line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("id,", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string[] parts = line.Split(','); + if (parts.Length < 4) + { + throw new InvalidDataException($"Not a CentrED# blueprint line: {line}"); + } + + list.Add(Entry(ParseNumber(parts[0]), ParseNumber(parts[1]), ParseNumber(parts[2]), ParseNumber(parts[3]))); + } + + return list; + } + + /// + /// The entries of a CentrED# TilesEntry file: <TilesEntry><Entry Name><Item ID X Y Z Hue />, + /// each a blueprint of its own. Missing attributes read as 0, like CentrED# reads them; an + /// entry without a name is "Unknown" and its index. Hues are dropped. + /// + public static List<(string Name, List Tiles)> ReadTilesEntries(string fileName) + { + var dom = new XmlDocument(); + dom.Load(fileName); + if (dom.DocumentElement?.Name != "TilesEntry") + { + throw new InvalidDataException("Not a TilesEntry file"); + } + + static int Attribute(XmlElement element, string name) + { + string value = element.GetAttribute(name); + return value.Length > 0 ? ParseNumber(value) : 0; + } + + var result = new List<(string, List)>(); + int index = 0; + foreach (XmlElement entry in dom.DocumentElement.GetElementsByTagName("Entry")) + { + var tiles = new List(); + foreach (XmlElement item in entry.GetElementsByTagName("Item")) + { + tiles.Add(Entry(Attribute(item, "ID"), Attribute(item, "X"), Attribute(item, "Y"), Attribute(item, "Z"))); + } + + string name = entry.GetAttribute("Name"); + result.Add((name.Length > 0 ? name : $"Unknown{index}", tiles)); + ++index; + } + + return result; + } + /// /// A UO House Editor design: a header line ("UOHE File ver 1.00",width,height,base), then /// one line per item: floor,x,y,altitude,item id (decimal). Floor 0 is the foundation with @@ -1377,6 +1452,61 @@ public void ExportToCsvFile(string fileName) } } + /// + /// A CentrED# blueprint, as its CsvWriter writes one: decimal ids, X and Y around the + /// blueprint's centre, Z from its lowest tile, hue and flags 0. CentrED# has no invisible + /// tiles, so every tile is written. + /// + public void ExportToCentredCsvFile(string fileName) + { + (int dx, int dy, int dz) = CentredOrigin(); + using var writer = new StreamWriter(fileName); + writer.WriteLine("id,x,y,z,hue,flags"); + foreach (MultiTileEntry e in SortedTiles) + { + writer.WriteLine(string.Create(CultureInfo.InvariantCulture, $"{e.ItemId},{e.OffsetX - dx},{e.OffsetY - dy},{e.OffsetZ - dz},0,0")); + } + } + + /// A CentrED# TilesEntry file holding this multi as its one entry, named . + public void ExportToTilesEntryFile(string fileName, string name) + { + (int dx, int dy, int dz) = CentredOrigin(); + using XmlWriter xmlWriter = XmlWriter.Create(fileName, new XmlWriterSettings { Indent = true }); + xmlWriter.WriteStartElement("TilesEntry"); + xmlWriter.WriteStartElement("Entry"); + xmlWriter.WriteAttributeString("Name", name); + foreach (MultiTileEntry e in SortedTiles) + { + xmlWriter.WriteStartElement("Item"); + xmlWriter.WriteAttributeString("ID", $"0x{e.ItemId:X4}"); + xmlWriter.WriteAttributeString("X", (e.OffsetX - dx).ToString(CultureInfo.InvariantCulture)); + xmlWriter.WriteAttributeString("Y", (e.OffsetY - dy).ToString(CultureInfo.InvariantCulture)); + xmlWriter.WriteAttributeString("Z", (e.OffsetZ - dz).ToString(CultureInfo.InvariantCulture)); + xmlWriter.WriteAttributeString("Hue", "0"); + xmlWriter.WriteEndElement(); // Item + } + + xmlWriter.WriteEndElement(); // Entry + xmlWriter.WriteEndElement(); // TilesEntry + } + + /// + /// What CentrED# subtracts to make a blueprint: the centre of its X/Y bounds (halfway, + /// rounded down, as its own save does) and its lowest Z. + /// + private (int X, int Y, int Z) CentredOrigin() + { + if (SortedTiles == null || SortedTiles.Length == 0) + { + return (0, 0, 0); + } + + int minX = SortedTiles.Min(e => e.OffsetX), maxX = SortedTiles.Max(e => e.OffsetX); + int minY = SortedTiles.Min(e => e.OffsetY), maxY = SortedTiles.Max(e => e.OffsetY); + return (minX + ((maxX - minX + 1) / 2), minY + ((maxY - minY + 1) / 2), SortedTiles.Min(e => e.OffsetZ)); + } + public void ExportToXmlFile(string fileName, string entryId) { using (var xmlWriter = XmlWriter.Create(fileName, new XmlWriterSettings { Indent = true })) diff --git a/Ultima/Multis.cs b/Ultima/Multis.cs index 517bbcbb..209e2377 100644 --- a/Ultima/Multis.cs +++ b/Ultima/Multis.cs @@ -61,7 +61,9 @@ public enum ImportType XML, UOH, // UO House Editor (UOHE) design WORLDFORGE, // WorldForge tile export - MULTIFORGE // POL multiforge package text: id x y z hue [multi flag] + MULTIFORGE, // POL multiforge package text: id x y z hue [multi flag] + CENTREDCSV, // CentrED# blueprint: "id,x,y,z,hue,flags" header, then one tile per line + TILESENTRY // CentrED# TilesEntry xml: named Entry elements holding Item ID X Y Z Hue } /// diff --git a/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs b/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs index e0d16f7c..9ce48f3d 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs @@ -46,7 +46,9 @@ private static readonly (Multis.ImportType Type, string Extension, string Descri (Multis.ImportType.XML, "xml", "XML"), (Multis.ImportType.UOH, "uoh", "UO House Editor design"), (Multis.ImportType.WORLDFORGE, "xml", "WorldForge export"), - (Multis.ImportType.MULTIFORGE, "txt", "Multiforge text (POL)") + (Multis.ImportType.MULTIFORGE, "txt", "Multiforge text (POL)"), + (Multis.ImportType.CENTREDCSV, "csv", "CentrED# blueprint"), + (Multis.ImportType.TILESENTRY, "xml", "CentrED# TilesEntry") }; /// @@ -355,6 +357,12 @@ public static bool ExportToFile(IWin32Window owner, MultiEditorComponentList lis case Multis.ImportType.MULTIFORGE: sdkList.ExportToMultiforgeFile(fileName); break; + case Multis.ImportType.CENTREDCSV: + sdkList.ExportToCentredCsvFile(fileName); + break; + case Multis.ImportType.TILESENTRY: + sdkList.ExportToTilesEntryFile(fileName, designName); + break; } } catch (Exception ex) diff --git a/UoFiddler.Plugin.MultiEditor/UI/MultiFileImport.cs b/UoFiddler.Plugin.MultiEditor/UI/MultiFileImport.cs index 7821df3a..18f7497c 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/MultiFileImport.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/MultiFileImport.cs @@ -81,7 +81,9 @@ public static readonly (Multis.ImportType Type, string Description, string Patte (Multis.ImportType.XML, "XML file (UOFiddler export)", "*.xml"), (Multis.ImportType.UOH, "UO House Editor design", "*.uoh"), (Multis.ImportType.WORLDFORGE, "WorldForge export", "*.xml"), - (Multis.ImportType.MULTIFORGE, "Multiforge text file (POL)", "*.txt") + (Multis.ImportType.MULTIFORGE, "Multiforge text file (POL)", "*.txt"), + (Multis.ImportType.CENTREDCSV, "CentrED# blueprint", "*.csv"), + (Multis.ImportType.TILESENTRY, "CentrED# TilesEntry", "*.xml") }; /// Short format names for lists. @@ -99,6 +101,8 @@ public static readonly (Multis.ImportType Type, string Description, string Patte Multis.ImportType.UOH => "UO House Editor", Multis.ImportType.WORLDFORGE => "WorldForge", Multis.ImportType.MULTIFORGE => "Multiforge", + Multis.ImportType.CENTREDCSV => "CentrED# CSV", + Multis.ImportType.TILESENTRY => "CentrED# TilesEntry", _ => type.ToString() }; @@ -139,7 +143,7 @@ public static IEnumerable CandidateFiles(string folder, bool recursive) ".xml" => DetectXml(path), ".uoa" => Multis.ImportType.UOAB, ".wsc" => Multis.ImportType.WSC, - ".csv" => Multis.ImportType.CSV, + ".csv" => DetectCsv(path), ".uox3" => Multis.ImportType.UOX3, ".uoh" => Multis.ImportType.UOH, ".idx" => IsDesignSet(path) ? Multis.ImportType.UOADESIGN : null, @@ -193,6 +197,23 @@ public static List Load(string path, Multis.ImportType type) result.Add(new ImportedMulti(name, type, path, design.ItemCount, () => LoadDesign(design))); } + break; + } + case Multis.ImportType.TILESENTRY: + { + // Each entry is a blueprint of its own, like the designs of a UOA batch. + List<(string Name, List Tiles)> entries = MultiComponentList.ReadTilesEntries(path); + foreach ((string name, List tiles) in entries) + { + int count = tiles.Count; + if (count > 0) + { + // The constructor empties the list it is given. + var multi = new MultiComponentList(tiles); + result.Add(new ImportedMulti(entries.Count == 1 ? title : $"{title} - {name}", type, path, count, multi)); + } + } + break; } default: @@ -349,7 +370,29 @@ private static bool TryParseNumber(string text, out int value) => private static int ParseNumber(string text) => TryParseNumber(text, out int value) ? value : -1; - /// A UOFiddler export has Item elements with an ID; a WorldForge export has tile elements with an id. + /// + /// A CentrED# blueprint starts with an "id,x,y,z" header; anything else is read as Punt's + /// multi tool CSV ("TileID,OffsetX..." header, hex ids). + /// + private static Multis.ImportType DetectCsv(string path) + { + using var reader = new StreamReader(path); + while (reader.ReadLine() is { } line) + { + line = line.Trim(); + if (line.Length > 0) + { + return line.StartsWith("id,x,y,z", StringComparison.OrdinalIgnoreCase) ? Multis.ImportType.CENTREDCSV : Multis.ImportType.CSV; + } + } + + return Multis.ImportType.CSV; + } + + /// + /// A UOFiddler export has Item elements with an ID; a WorldForge export has tile elements with an id; + /// a CentrED# TilesEntry file has a TilesEntry root. + /// private static Multis.ImportType? DetectXml(string path) { var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null }; @@ -363,6 +406,12 @@ private static bool TryParseNumber(string text, out int value) => continue; } + // CentrED# names the root; its Item elements look like a UOFiddler export's. + if (reader.LocalName == "TilesEntry") + { + return Multis.ImportType.TILESENTRY; + } + if (reader.LocalName == "Item" && reader.GetAttribute("ID") != null) { return Multis.ImportType.XML; From 663b872f22b8a7d29377222f4179e02e2d1177cd Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:26:53 +0100 Subject: [PATCH 3/5] List selected tiles in the inspector and trim tile tooltips --- .../Editing/EditorSession.cs | 40 +++ .../Rendering/SceneRenderer.cs | 18 +- .../UI/InspectorPanel.Designer.cs | 97 +++++++ .../UI/InspectorPanel.cs | 248 +++++++++++++++++- .../UI/TileBrowserThumbnails.cs | 51 ++-- 5 files changed, 415 insertions(+), 39 deletions(-) diff --git a/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.cs b/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.cs index af7dc356..e14adb9d 100644 --- a/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.cs +++ b/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.cs @@ -455,6 +455,46 @@ public void ClearSelection() Invalidated?.Invoke(); } + /// Takes tiles out of the selection, with one change notice. + public void Deselect(IEnumerable tiles) + { + bool changed = false; + foreach (MultiTile tile in tiles) + { + changed |= _selection.Remove(tile); + } + + if (changed) + { + SelectionChanged?.Invoke(); + Invalidated?.Invoke(); + } + } + + private HashSet _listHighlight = new HashSet(); + + /// + /// Tiles pointed at outside the canvas (a row of the selection list), drawn like the hovered + /// tile. Empty for none. + /// + public IReadOnlyCollection ListHighlight + { + get => _listHighlight; + set + { + var next = value == null ? new HashSet() : new HashSet(value); + if (next.SetEquals(_listHighlight)) + { + return; + } + + _listHighlight = next; + Invalidated?.Invoke(); + } + } + + public bool IsListHighlighted(MultiTile tile) => _listHighlight.Count > 0 && _listHighlight.Contains(tile); + /// True when a tile can be hovered, selected and edited with the current view. public bool IsEditable(MultiTile tile) { diff --git a/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs b/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs index 401ed2d4..7b005911 100644 --- a/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs +++ b/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs @@ -202,7 +202,7 @@ private void AddTile(EditorSession session, MultiEditorComponentList list, Multi } SpriteTint tint; - if (tile == hover) + if (tile == hover || session.IsListHighlighted(tile)) { tint = _hover; } @@ -503,10 +503,20 @@ private void AddPlan(EditorSession session) if (session.HoverTile is { } hover) { - Point o = PlanView.CellOrigin(hover.X, hover.Y); - _lines.Rectangle(new RectangleF(o.X + 1, o.Y + 1, cell - 2, cell - 2), _marqueeLine); - _lines.Rectangle(new RectangleF(o.X + 2, o.Y + 2, cell - 4, cell - 4), _marqueeLine); + OutlinePlanCell(hover, cell); } + + foreach (MultiTile tile in session.ListHighlight) + { + OutlinePlanCell(tile, cell); + } + } + + private void OutlinePlanCell(MultiTile tile, int cell) + { + Point o = PlanView.CellOrigin(tile.X, tile.Y); + _lines.Rectangle(new RectangleF(o.X + 1, o.Y + 1, cell - 2, cell - 2), _marqueeLine); + _lines.Rectangle(new RectangleF(o.X + 2, o.Y + 2, cell - 4, cell - 4), _marqueeLine); } private static MultiTile TopFill(EditorSession session, System.Collections.Generic.List tiles, int maxHeight, bool skipCovers) diff --git a/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.Designer.cs index 48b6957f..df3307c4 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.Designer.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.Designer.cs @@ -76,6 +76,14 @@ private void InitializeComponent() groupSetZButton = new System.Windows.Forms.Button(); groupDeleteButton = new System.Windows.Forms.Button(); groupHideButton = new System.Windows.Forms.Button(); + selectionUnselectButton = new System.Windows.Forms.Button(); + selectionKeepButton = new System.Windows.Forms.Button(); + _groupById = new System.Windows.Forms.CheckBox(); + _selectionList = new System.Windows.Forms.ListView(); + selectionColumnTile = new System.Windows.Forms.ColumnHeader(); + selectionColumnZ = new System.Windows.Forms.ColumnHeader(); + selectionColumnWhere = new System.Windows.Forms.ColumnHeader(); + selectionColumnName = new System.Windows.Forms.ColumnHeader(); _hint = new System.Windows.Forms.Label(); floorsBox.SuspendLayout(); floorsPage.SuspendLayout(); @@ -471,12 +479,17 @@ private void InitializeComponent() _group.Controls.Add(groupSetZButton); _group.Controls.Add(groupDeleteButton); _group.Controls.Add(groupHideButton); + _group.Controls.Add(selectionUnselectButton); + _group.Controls.Add(selectionKeepButton); + _group.Controls.Add(_groupById); + _group.Controls.Add(_selectionList); _group.Dock = System.Windows.Forms.DockStyle.Fill; _group.Location = new System.Drawing.Point(0, 0); _group.Name = "_group"; _group.Size = new System.Drawing.Size(144, 0); _group.TabIndex = 1; _group.Visible = false; + _group.Resize += Group_Resize; // // _groupLabel // @@ -581,6 +594,82 @@ private void InitializeComponent() groupHideButton.UseVisualStyleBackColor = true; groupHideButton.Click += GroupHideButton_Click; // + // selectionUnselectButton + // + selectionUnselectButton.Location = new System.Drawing.Point(124, 146); + selectionUnselectButton.Margin = new System.Windows.Forms.Padding(1); + selectionUnselectButton.Name = "selectionUnselectButton"; + selectionUnselectButton.Size = new System.Drawing.Size(70, 26); + selectionUnselectButton.TabIndex = 10; + selectionUnselectButton.Text = "Unselect"; + selectionUnselectButton.UseVisualStyleBackColor = true; + selectionUnselectButton.Click += SelectionUnselectButton_Click; + // + // selectionKeepButton + // + selectionKeepButton.Location = new System.Drawing.Point(198, 146); + selectionKeepButton.Margin = new System.Windows.Forms.Padding(1); + selectionKeepButton.Name = "selectionKeepButton"; + selectionKeepButton.Size = new System.Drawing.Size(76, 26); + selectionKeepButton.TabIndex = 11; + selectionKeepButton.Text = "Keep only"; + selectionKeepButton.UseVisualStyleBackColor = true; + selectionKeepButton.Click += SelectionKeepButton_Click; + // + // _groupById + // + _groupById.AutoSize = true; + _groupById.Location = new System.Drawing.Point(6, 180); + _groupById.Name = "_groupById"; + _groupById.Size = new System.Drawing.Size(110, 19); + _groupById.TabIndex = 12; + _groupById.Text = "Group by tile id"; + _groupById.UseVisualStyleBackColor = true; + _groupById.CheckedChanged += GroupById_CheckedChanged; + // + // _selectionList + // + _selectionList.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { selectionColumnTile, selectionColumnZ, selectionColumnWhere, selectionColumnName }); + _selectionList.FullRowSelect = true; + _selectionList.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.Nonclickable; + _selectionList.HideSelection = false; + _selectionList.Location = new System.Drawing.Point(4, 204); + _selectionList.Name = "_selectionList"; + _selectionList.Size = new System.Drawing.Size(280, 100); + _selectionList.SmallImageList = _icons; + _selectionList.TabIndex = 13; + _selectionList.UseCompatibleStateImageBehavior = false; + _selectionList.View = System.Windows.Forms.View.Details; + _selectionList.VirtualMode = true; + _selectionList.RetrieveVirtualItem += SelectionList_RetrieveVirtualItem; + _selectionList.SelectedIndexChanged += SelectionList_SelectedIndexChanged; + _selectionList.VirtualItemsSelectionRangeChanged += SelectionList_VirtualItemsSelectionRangeChanged; + _selectionList.DoubleClick += SelectionList_DoubleClick; + _selectionList.KeyDown += SelectionList_KeyDown; + _selectionList.MouseMove += SelectionList_MouseMove; + _selectionList.MouseLeave += SelectionList_MouseLeave; + _selectionList.MouseUp += SelectionList_MouseUp; + // + // selectionColumnTile + // + selectionColumnTile.Text = "Tile"; + selectionColumnTile.Width = 96; + // + // selectionColumnZ + // + selectionColumnZ.Text = "Z"; + selectionColumnZ.Width = 44; + // + // selectionColumnWhere + // + selectionColumnWhere.Text = "X, Y"; + selectionColumnWhere.Width = 56; + // + // selectionColumnName + // + selectionColumnName.Text = "Name"; + selectionColumnName.Width = 120; + // // _hint // _hint.Dock = System.Windows.Forms.DockStyle.Fill; @@ -666,6 +755,14 @@ private void InitializeComponent() private System.Windows.Forms.Button groupSetZButton; private System.Windows.Forms.Button groupDeleteButton; private System.Windows.Forms.Button groupHideButton; + private System.Windows.Forms.Button selectionUnselectButton; + private System.Windows.Forms.Button selectionKeepButton; + private System.Windows.Forms.CheckBox _groupById; + private System.Windows.Forms.ListView _selectionList; + private System.Windows.Forms.ColumnHeader selectionColumnTile; + private System.Windows.Forms.ColumnHeader selectionColumnZ; + private System.Windows.Forms.ColumnHeader selectionColumnWhere; + private System.Windows.Forms.ColumnHeader selectionColumnName; private System.Windows.Forms.Label _hint; } } diff --git a/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.cs b/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.cs index 14a2a4a5..aff3c252 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.cs @@ -25,13 +25,16 @@ namespace UoFiddler.Plugin.MultiEditor.UI internal sealed partial class InspectorPanel : UserControl { private const int _iconSize = 32; - private const TileFlag _shownFlags = TileFlag.Surface | TileFlag.Impassable | TileFlag.Wall | TileFlag.Bridge | - TileFlag.Roof | TileFlag.Door | TileFlag.Window | TileFlag.Foliage | - TileFlag.Wet | TileFlag.Background | TileFlag.Translucent | TileFlag.Damaging; private readonly Dictionary _iconIndex = new Dictionary(); private readonly List<(string Name, int Floor, int Ceiling)> _levels = new List<(string Name, int Floor, int Ceiling)>(); + /// Rows of the selection list: one tile each, or every selected tile of one id when grouped. + private readonly List _selectionRows = new List(); + private bool _groupedRows; + private int _hoverRow = -1; + private ContextMenuStrip _selectionMenu; + private EditorSession _session; private bool _updating; @@ -48,6 +51,7 @@ public InspectorPanel() private void InspectorPanel_Disposed(object sender, EventArgs e) { + _selectionMenu?.Dispose(); if (_session != null) { _session.SelectionChanged -= Session_SelectionChanged; @@ -132,6 +136,134 @@ private void GroupHideButton_Click(object sender, EventArgs e) _session?.HideSelection(); } + private void SelectionUnselectButton_Click(object sender, EventArgs e) + { + UnselectRows(); + } + + private void SelectionKeepButton_Click(object sender, EventArgs e) + { + KeepRows(SelectedRowTiles()); + } + + private void GroupById_CheckedChanged(object sender, EventArgs e) + { + RebuildSelectionList(); + } + + private void Group_Resize(object sender, EventArgs e) + { + // Sized by hand: the list fills the rest of the panel below the buttons. + _selectionList.Size = new Size(Math.Max(40, _group.ClientSize.Width - 8), + Math.Max(40, _group.ClientSize.Height - _selectionList.Top - 4)); + } + + private void SelectionList_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e) + { + MultiTile[] row = e.ItemIndex < _selectionRows.Count ? _selectionRows[e.ItemIndex] : Array.Empty(); + if (row.Length == 0) + { + e.Item = new ListViewItem(string.Empty); + e.Item.SubItems.Add(string.Empty); + e.Item.SubItems.Add(string.Empty); + e.Item.SubItems.Add(string.Empty); + return; + } + + MultiTile first = row[0]; + if (_groupedRows) + { + int min = row.Min(t => t.Z); + int max = row.Max(t => t.Z); + e.Item = new ListViewItem($"0x{first.Id:X4}", IconFor(first.Id)); + e.Item.SubItems.Add(row.Length.ToString("N0")); + e.Item.SubItems.Add(min == max ? min.ToString() : $"{min}–{max}"); + } + else + { + e.Item = new ListViewItem(first.Invisible ? $"0x{first.Id:X4} *" : $"0x{first.Id:X4}", IconFor(first.Id)); + e.Item.SubItems.Add(first.Z.ToString()); + e.Item.SubItems.Add($"{first.X}, {first.Y}"); + } + + e.Item.SubItems.Add(TileName(first.Id)); + } + + private void SelectionList_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateListHighlight(); + } + + // Shift ranges in a virtual list. + private void SelectionList_VirtualItemsSelectionRangeChanged(object sender, ListViewVirtualItemsSelectionRangeChangedEventArgs e) + { + UpdateListHighlight(); + } + + private void SelectionList_DoubleClick(object sender, EventArgs e) + { + int index = _selectionList.FocusedItem?.Index ?? -1; + if (index >= 0 && index < _selectionRows.Count) + { + KeepRows(_selectionRows[index]); + } + } + + private void SelectionList_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Delete) + { + UnselectRows(); + e.Handled = true; + } + else if (e.KeyCode == Keys.Enter) + { + KeepRows(SelectedRowTiles()); + e.Handled = e.SuppressKeyPress = true; + } + else if (e.KeyCode == Keys.A && e.Control) + { + _selectionList.BeginUpdate(); + for (int i = 0; i < _selectionRows.Count; ++i) + { + _selectionList.SelectedIndices.Add(i); + } + + _selectionList.EndUpdate(); + e.Handled = e.SuppressKeyPress = true; + } + } + + private void SelectionList_MouseMove(object sender, MouseEventArgs e) + { + int row = _selectionList.GetItemAt(e.X, e.Y)?.Index ?? -1; + if (row != _hoverRow) + { + _hoverRow = row; + UpdateListHighlight(); + } + } + + private void SelectionList_MouseLeave(object sender, EventArgs e) + { + _hoverRow = -1; + UpdateListHighlight(); + } + + private void SelectionList_MouseUp(object sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Right || _selectionList.SelectedIndices.Count == 0) + { + return; + } + + _selectionMenu ??= new ContextMenuStrip(); + _selectionMenu.Items.Clear(); + _selectionMenu.Items.Add("Unselect", null, (_, _) => UnselectRows()); + _selectionMenu.Items.Add("Keep only these", null, (_, _) => KeepRows(SelectedRowTiles())); + _selectionMenu.Show(_selectionList, e.Location); + } + private void FloorList_SelectedIndexChanged(object sender, EventArgs e) { OnFloorChosen(); @@ -193,6 +325,7 @@ public void Bind(EditorSession session) { if (_session != null) { + _session.ListHighlight = null; _session.SelectionChanged -= Session_SelectionChanged; _session.DocumentChanged -= Session_DocumentChanged; _session.ViewChanged -= Session_ViewChanged; @@ -264,6 +397,8 @@ private void RefreshSelection() _groupLabel.Text = $"{count} tiles selected, Z {min}–{max}"; _groupZ.Value = Math.Clamp(min, (int)_groupZ.Minimum, (int)_groupZ.Maximum); } + + RebuildSelectionList(); } finally { @@ -271,13 +406,109 @@ private void RefreshSelection() } } + /// + /// Fills the list of selected tiles (several only): one row per tile, top Z first, or one + /// row per tile id with the most used first. + /// + private void RebuildSelectionList() + { + _selectionRows.Clear(); + _groupedRows = _groupById.Checked; + if (_session != null && _session.Selection.Count > 1) + { + IEnumerable sorted = _session.Selection.OrderByDescending(t => t.Z).ThenBy(t => t.Y).ThenBy(t => t.X); + if (_groupedRows) + { + _selectionRows.AddRange(sorted.GroupBy(t => t.Id).OrderByDescending(g => g.Count()).ThenBy(g => g.Key).Select(g => g.ToArray())); + } + else + { + _selectionRows.AddRange(sorted.Select(t => new[] { t })); + } + } + + selectionColumnZ.Text = _groupedRows ? "Count" : "Z"; + selectionColumnWhere.Text = _groupedRows ? "Z" : "X, Y"; + _hoverRow = -1; + _selectionList.SelectedIndices.Clear(); + _selectionList.VirtualListSize = _selectionRows.Count; + _selectionList.Invalidate(); + UpdateListHighlight(); + } + + private List SelectedRowTiles() + { + var tiles = new List(); + foreach (int index in _selectionList.SelectedIndices) + { + if (index < _selectionRows.Count) + { + tiles.AddRange(_selectionRows[index]); + } + } + + return tiles; + } + + /// The tiles of the row under the mouse glow on the canvas, else those of the chosen rows. + private void UpdateListHighlight() + { + if (_session == null) + { + return; + } + + _session.ListHighlight = _hoverRow >= 0 && _hoverRow < _selectionRows.Count ? _selectionRows[_hoverRow] : SelectedRowTiles(); + } + + /// Takes the chosen rows out of the selection and moves to the row that took their place. + private void UnselectRows() + { + List tiles = SelectedRowTiles(); + if (_session == null || tiles.Count == 0) + { + return; + } + + int first = _selectionList.SelectedIndices[0]; + _session.Deselect(tiles); + if (_selectionRows.Count > 0) + { + int index = Math.Min(first, _selectionRows.Count - 1); + _selectionList.SelectedIndices.Add(index); + _selectionList.FocusedItem = _selectionList.Items[index]; + _selectionList.EnsureVisible(index); + } + } + + private void KeepRows(IReadOnlyCollection tiles) + { + if (_session != null && tiles.Count > 0) + { + // Selecting rebuilds this list, so let the ListView finish its own event first. + var keep = tiles.ToList(); + BeginInvoke(() => _session?.SetSelection(keep, add: false)); + } + } + + private static string TileName(ushort id) => TileBrowserThumbnails.NameOf(id); + private void ShowSingle(MultiTile tile) { - ItemData data = TileData.ItemTable[tile.Id]; _thumb.Image = Art.GetStatic(tile.Id); - _idLabel.Text = $"0x{tile.Id:X4} {data.Name}"; - TileFlag flags = data.Flags & _shownFlags; - _infoLabel.Text = $"Height {data.Height}\r\n{(flags == 0 ? "no flags" : flags.ToString())}"; + if (TileBrowserThumbnails.HasItemData(tile.Id)) + { + ItemData data = TileData.ItemTable[tile.Id]; + _idLabel.Text = $"0x{tile.Id:X4} {data.Name}"; + List flags = TileBrowserThumbnails.BuildFlagNames(data.Flags); + string height = (data.Flags & TileFlag.Bridge) != 0 ? $"Height {data.Height} (calc {data.CalcHeight})" : $"Height {data.Height}"; + _infoLabel.Text = $"{height}\r\n{(flags.Count == 0 ? "no flags" : string.Join(", ", flags))}"; + } + else + { + _idLabel.Text = $"0x{tile.Id:X4}"; + _infoLabel.Text = "No tiledata entry"; + } _x.Maximum = Math.Max(0, _session.CompList.Width - 1); _y.Maximum = Math.Max(0, _session.CompList.Height - 1); @@ -295,10 +526,9 @@ private void ShowSingle(MultiTile tile) for (int i = cell.Count - 1; i >= 0; --i) { MultiTile entry = cell[i]; - ItemData entryData = TileData.ItemTable[entry.Id]; var item = new ListViewItem(entry.Invisible ? $"0x{entry.Id:X4} *" : $"0x{entry.Id:X4}", IconFor(entry.Id)) { Tag = entry }; item.SubItems.Add(entry.Z.ToString()); - item.SubItems.Add(entryData.Name ?? string.Empty); + item.SubItems.Add(TileName(entry.Id)); _stack.Items.Add(item); if (entry == tile) { diff --git a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserThumbnails.cs b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserThumbnails.cs index 8284aa5a..b787b698 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserThumbnails.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserThumbnails.cs @@ -27,30 +27,39 @@ internal sealed class TileBrowserThumbnails : IDisposable // Enough for several screens of 64 px cells; a full clear past it is cheap and rare. private const int _maxEntries = 3000; - private static readonly (TileFlag Flag, string Name)[] _summaryFlags = + /// The tiledata flags that matter when building a multi, with the names shown for them. + private static readonly (TileFlag Flag, string Name)[] _buildFlags = { - (TileFlag.Background, "Background"), - (TileFlag.Wall, "Wall"), (TileFlag.Impassable, "Impassable"), + (TileFlag.Wall, "Wall"), + (TileFlag.NoShoot, "No shoot"), (TileFlag.Surface, "Surface"), (TileFlag.Bridge, "Bridge"), - (TileFlag.Roof, "Roof"), + (TileFlag.StairBack, "Stair back"), + (TileFlag.StairRight, "Stair right"), (TileFlag.Door, "Door"), (TileFlag.Window, "Window"), - (TileFlag.StairBack, "StairBack"), - (TileFlag.StairRight, "StairRight"), + (TileFlag.Roof, "Roof"), + (TileFlag.Background, "Background"), (TileFlag.Foliage, "Foliage"), - (TileFlag.Container, "Container"), - (TileFlag.LightSource, "Light"), - (TileFlag.Translucent, "Translucent"), - (TileFlag.Transparent, "Transparent"), - (TileFlag.Animation, "Animated"), - (TileFlag.Wet, "Wet"), - (TileFlag.NoShoot, "NoShoot"), - (TileFlag.HoverOver, "HoverOver"), - (TileFlag.Generic, "Generic") + (TileFlag.HoverOver, "Hover over") }; + /// The names of the building flags a tile has, in a fixed order. + public static List BuildFlagNames(TileFlag flags) + { + var names = new List(); + foreach ((TileFlag flag, string name) in _buildFlags) + { + if ((flags & flag) != 0) + { + names.Add(name); + } + } + + return names; + } + private readonly Dictionary<(int Id, int Box), Bitmap> _cache = new Dictionary<(int, int), Bitmap>(); /// The art fitted into a box of the given size, or null when the tile has no art. @@ -137,17 +146,7 @@ public static string Describe(int id) text.Append(CultureInfo.InvariantCulture, $" (calc {data.CalcHeight})"); } - text.Append(CultureInfo.InvariantCulture, $", weight {data.Weight}"); - - var flags = new List(); - foreach ((TileFlag flag, string flagName) in _summaryFlags) - { - if ((data.Flags & flag) != 0) - { - flags.Add(flagName); - } - } - + List flags = BuildFlagNames(data.Flags); if (flags.Count > 0) { text.AppendLine(); From db61e1cb899b5b5f6a2d6feb733bf0afcddcbfe3 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:06:13 +0100 Subject: [PATCH 4/5] Zoom 100% button, full shortcuts list, grid under tiles, 400 undo steps Ctrl+drag on empty space now adds to the selection in the Select tool. --- .../Classes/MultiEditorComponentList.cs | 2 +- .../Rendering/SceneRenderer.cs | 26 +++---- .../Tools/SelectTool.cs | 3 +- .../UI/EditorCommands.cs | 10 ++- .../UI/MultiEditorControl.cs | 6 +- .../UI/ShortcutsForm.cs | 70 +++++++++++++++---- 6 files changed, 86 insertions(+), 31 deletions(-) diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs index f792c388..0d7ec153 100644 --- a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs +++ b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs @@ -23,7 +23,7 @@ namespace UoFiddler.Plugin.MultiEditor.Classes /// internal partial class MultiEditorComponentList { - public const int DefaultUndoStackSize = 200; + public const int DefaultUndoStackSize = 400; public const int GapXMod = 44; public const int GapYMod = 22; diff --git a/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs b/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs index 7b005911..096d5bfd 100644 --- a/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs +++ b/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs @@ -17,8 +17,8 @@ namespace UoFiddler.Plugin.MultiEditor.Rendering { /// - /// Turns an editor session into sprites and lines: the multi in draw order with its view - /// highlights, the grid, then what the active tool shows. + /// Turns an editor session into sprites and lines: the ground grid and outline, the multi in + /// draw order with its view highlights over them, then what the active tool shows. /// internal sealed class SceneRenderer : IDisposable, IToolOverlay { @@ -104,21 +104,23 @@ public void Draw(EditorSession session, Camera2D camera, Size viewport) if (session.PlanMode) { AddPlan(session); + LastSpriteCount += _sprites.Count; + _sprites.Flush(_atlas, camera, viewport); + _lines.Flush(camera, viewport); } else { - AddTiles(session); - } - - LastSpriteCount += _sprites.Count; - _sprites.Flush(_atlas, camera, viewport); - - if (!session.PlanMode) - { + // The grid and the outline lie on the ground (Z 0): drawn after the reference + // underlay but before the tiles, so whatever stands on the ground covers them. + LastSpriteCount += _sprites.Count; + _sprites.Flush(_atlas, camera, viewport); AddGrid(session); - } + _lines.Flush(camera, viewport); - _lines.Flush(camera, viewport); + AddTiles(session); + LastSpriteCount += _sprites.Count; + _sprites.Flush(_atlas, camera, viewport); + } // Tool overlay: sprites first (ghost tiles), then the lines on top of them. session.ActiveTool.PaintOverlay(this); diff --git a/UoFiddler.Plugin.MultiEditor/Tools/SelectTool.cs b/UoFiddler.Plugin.MultiEditor/Tools/SelectTool.cs index 1afc6312..1117c30d 100644 --- a/UoFiddler.Plugin.MultiEditor/Tools/SelectTool.cs +++ b/UoFiddler.Plugin.MultiEditor/Tools/SelectTool.cs @@ -47,8 +47,9 @@ public override void OnMouseDown(MouseEventArgs e) _start = Context.MouseLoc; _moveDx = _moveDy = 0; } - else if (hover == null && !CtrlHeld) + else if (hover == null) { + // With Ctrl the box adds to the selection when it is let go. _marquee = true; _start = _end = Context.MouseLoc; } diff --git a/UoFiddler.Plugin.MultiEditor/UI/EditorCommands.cs b/UoFiddler.Plugin.MultiEditor/UI/EditorCommands.cs index 8d651b88..fa13537f 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/EditorCommands.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/EditorCommands.cs @@ -279,6 +279,9 @@ internal static class Glyphs public const string ZoomOut = "\uE71F"; public const string Fit = "\uE9A6"; public const string Center = "\uE81D"; + + /// Zoom 100%: Segoe MDL2 has no actual-size icon, so it is the text "1:1". + public const string ActualSize = "1:1"; public const string RotateLeft = "\uE7AD"; public const string RotateRight = "\uE7AD"; public const string Help = "\uE897"; @@ -288,7 +291,12 @@ public static Bitmap Render(string glyph, int size, Color color) var bitmap = new Bitmap(size, size); using Graphics g = Graphics.FromImage(bitmap); g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; - using var font = new Font("Segoe MDL2 Assets", size * 0.72f, GraphicsUnit.Pixel); + + // Icons are private-use characters of the icon font; anything else is short text drawn in a UI font. + bool icon = glyph.Length > 0 && glyph[0] >= '' && glyph[0] <= ''; + using var font = icon + ? new Font("Segoe MDL2 Assets", size * 0.72f, GraphicsUnit.Pixel) + : new Font("Segoe UI", size * 0.55f, FontStyle.Bold, GraphicsUnit.Pixel); using var brush = new SolidBrush(color); using var format = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; g.DrawString(glyph, font, brush, new RectangleF(0, 0, size, size), format); diff --git a/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs index ee97834f..185d338d 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs @@ -540,7 +540,7 @@ private void DefineCommands() _center = Command("View", "Center design", () => _canvas.CenterView(), Keys.Shift | Keys.Home, hasDoc, glyph: Glyphs.Center); _zoomIn = Command("View", "Zoom in", () => _canvas.ZoomStep(1), Keys.Add, glyph: Glyphs.ZoomIn, alt: Keys.Shift | Keys.Oemplus); _zoomOut = Command("View", "Zoom out", () => _canvas.ZoomStep(-1), Keys.Subtract, glyph: Glyphs.ZoomOut, alt: Keys.OemMinus); - _zoomReset = Command("View", "Zoom 100%", () => _canvas.SetZoom(1f), Keys.Control | Keys.D0, alt: Keys.Control | Keys.NumPad0); + _zoomReset = Command("View", "Zoom 100%", () => _canvas.SetZoom(1f), Keys.Control | Keys.D0, glyph: Glyphs.ActualSize, alt: Keys.Control | Keys.NumPad0); _performanceInfo = Command("View", "Performance info", TogglePerformanceInfo, Keys.F12, isChecked: () => _performanceShown); AddTool(ToolKind.Select, "Select", Keys.S, Glyphs.Select); @@ -1173,12 +1173,12 @@ private void BuildMainStrip() InsertButtons(strip, _mainSeparator3, _cut, _copy, _paste, _delete); InsertButtons(strip, _mainSeparator4, _workPlane, _grid, _hideWalls); - // Zoom, then help, after the other groups: zoom out, the zoom, zoom in, fit and centre. + // Zoom, then help, after the other groups: zoom out, the zoom, zoom in, 100%, fit and centre. int at = strip.Items.IndexOf(_zoomLabel); strip.Items.Insert(at++, new ToolStripSeparator()); strip.Items.Insert(at++, _commands.Button(_zoomOut)); at++; // the zoom label - foreach (EditorCommand command in new[] { _zoomIn, _fit, _center }) + foreach (EditorCommand command in new[] { _zoomIn, _zoomReset, _fit, _center }) { strip.Items.Insert(at++, _commands.Button(command)); } diff --git a/UoFiddler.Plugin.MultiEditor/UI/ShortcutsForm.cs b/UoFiddler.Plugin.MultiEditor/UI/ShortcutsForm.cs index 3960d916..2699fb70 100644 --- a/UoFiddler.Plugin.MultiEditor/UI/ShortcutsForm.cs +++ b/UoFiddler.Plugin.MultiEditor/UI/ShortcutsForm.cs @@ -16,8 +16,9 @@ namespace UoFiddler.Plugin.MultiEditor.UI { /// - /// The list of keyboard shortcuts and mouse actions (F1). The rows come from the editor's - /// command registry, so the list always matches the keys that work. + /// The list of keyboard shortcuts and mouse actions (F1). The command rows come from the + /// editor's command registry, so they always match the keys that work; the mouse, tool and + /// sidebar rows are written here. /// internal partial class ShortcutsForm : Form { @@ -43,17 +44,60 @@ public void ShowCommands(IEnumerable commands) } } - Heading("Mouse"); - foreach ((string what, string how) in new[] - { - ("Pan", "Right or middle drag, Space + drag"), - ("Zoom at the cursor", "Wheel"), - ("Step through the floors", "Shift + wheel"), - ("Context menu, or cancel a drag or paste", "Right click"), - ("Select the tile under the cursor (any tool)", "Middle click"), - ("Move the selection one cell", "Arrow keys"), - ("Pan one cell", "Ctrl + arrow keys") - }) + // Keys handled outside the command registry: kept here by hand, so check them when a + // tool or a sidebar list gets a new key. + Section("Mouse", + ("Pan", "Right or middle drag, Space + drag"), + ("Zoom at the cursor", "Wheel"), + ("Step through the floors", "Shift + wheel"), + ("Context menu, or cancel a drag or paste", "Right click"), + ("Select the tile under the cursor (any tool)", "Middle click"), + ("Move the selection one cell", "Arrow keys"), + ("Pan one cell", "Ctrl + arrow keys")); + + Section("Keys held with a tool", + ("Move the selection (Select)", "Drag a selected tile"), + ("Add a tile to the selection or take it out (Select)", "Ctrl + click"), + ("Add to the selection (Select, Lasso)", "Ctrl + drag"), + ("Keep the line straight (Line)", "Shift + drag"), + ("One straight wall instead of a room (Walls)", "Shift + drag"), + ("Erase walls (Walls)", "Ctrl + drag"), + ("Fit the roof to the walls (Roof)", "Click without dragging"), + ("Turn the ridge the other way (Roof)", "Shift"), + ("Gables at one end or both (Roof)", "Ctrl")); + + Section("Tile browser", + ("Go from the search box to the results", "Down"), + ("Pick the first result", "Enter in the search box"), + ("Clear the search", "Esc in the search box"), + ("Move through the tiles", "Arrow keys, Home, End, Page Up, Page Down"), + ("Pick the tile", "Enter, click"), + ("Remove a tile from the favorites page", "Delete"), + ("Place a blueprint", "Click, Enter"), + ("Rename a blueprint", "F2"), + ("Delete a blueprint", "Delete"), + ("Open UO Architect designs (Designs page)", "Enter, double click")); + + Section("Selected tiles list", + ("Take the chosen rows out of the selection", "Delete"), + ("Keep only the chosen rows selected", "Enter"), + ("Keep only this row selected", "Double click"), + ("Choose every row", "Ctrl + A")); + + // The keys column fits its longest entry, and the window grows to show both columns whole. + _list.AutoResizeColumn(_keysColumn.Index, ColumnHeaderAutoResizeStyle.ColumnContent); + _keysColumn.Width = Math.Max(_keysColumn.Width + 12, 160); + int needed = _commandColumn.Width + _keysColumn.Width + SystemInformation.VerticalScrollBarWidth + 8; + if (_list.ClientSize.Width < needed) + { + Width += needed - _list.ClientSize.Width; + } + } + + private void Section(string heading, params (string What, string How)[] rows) + { + Heading(heading); + foreach ((string what, string how) in rows) { Row(what, how); } From 3fa9e69c22f43fef82706cd435e43a97bbd3ac6b Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:23:52 +0100 Subject: [PATCH 5/5] Update change log and version --- UoFiddler/Forms/AboutBoxForm.resx | 12 +++++++++++- UoFiddler/UoFiddler.csproj | 6 +++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/UoFiddler/Forms/AboutBoxForm.resx b/UoFiddler/Forms/AboutBoxForm.resx index ca2aa07a..46f51168 100644 --- a/UoFiddler/Forms/AboutBoxForm.resx +++ b/UoFiddler/Forms/AboutBoxForm.resx @@ -118,7 +118,17 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Version 4.28.0 + Version 4.28.1 +- Multi Editor: prefabs are now blueprints, kept in MultiEditorBlueprints; an existing MultiEditorPrefabs folder is moved there on first use +- Multi Editor: save picked multis and UO Architect designs as blueprints in bulk from Open from client, Import files, Import from folder and the Designs page +- Multi Editor: blueprints keep their name in the file and show their footprint; the Blueprints page has a group drop-down and favorite groups +- Multi Editor: blueprint previews stack tiles like the canvas, so tree foliage covers the trunk, and are drawn as they scroll into view +- Multi Editor: imports and exports CentrED# blueprints (CSV and TilesEntry XML) +- Multi Editor: the inspector lists the selected tiles; rows highlight their tiles on the canvas and can be taken out of the selection +- Multi Editor: tile tooltips show the height and the building flags only +- Multi Editor: Zoom 100% toolbar button, every key in the shortcuts list (F1), the grid and outline drawn under the tiles, 400 undo steps, and Ctrl+drag adds a box to the selection + +Version 4.28.0 - Multi Editor: replaced by an OpenGL editor (3.0.0) with a tool palette, document tabs, a tile browser with house styles, blueprints and UO Architect designs, floors, a plan view and a preview - Multi Editor: document tabs have close buttons and Close all designs, and the mouse wheel over the tab strip switches designs - Multi Editor: one work plane follows the chosen floor and moves a Z at a time with Ctrl+PgUp/PgDn; its Z shows in the status bar diff --git a/UoFiddler/UoFiddler.csproj b/UoFiddler/UoFiddler.csproj index c000f846..91bd1aae 100644 --- a/UoFiddler/UoFiddler.csproj +++ b/UoFiddler/UoFiddler.csproj @@ -9,9 +9,9 @@ UoFiddler UoFiddler Copyright © 2026 - 4.28.0 - 4.28.0 - 4.28.0 + 4.28.1 + 4.28.1 + 4.28.1 true