diff --git a/MinecraftClient/Commands/Inventory.cs b/MinecraftClient/Commands/Inventory.cs
index 5936afd0..cc90f185 100644
--- a/MinecraftClient/Commands/Inventory.cs
+++ b/MinecraftClient/Commands/Inventory.cs
@@ -435,7 +435,7 @@ namespace MinecraftClient.Commands
return r.SetAndReturn(CmdResult.Status.Fail, msg);
}
- if (container.Type != ContainerType.PlayerInventory)
+ if (!Tui.ContainerViewBase.HasTuiSupport(container.Type))
{
handler.Log.Warn(string.Format(Translations.cmd_inventory_tui_unsupported_container, inventoryId));
return r.SetAndReturn(CmdResult.Status.Fail);
diff --git a/MinecraftClient/Inventory/Container.cs b/MinecraftClient/Inventory/Container.cs
index 98908655..f2258fee 100644
--- a/MinecraftClient/Inventory/Container.cs
+++ b/MinecraftClient/Inventory/Container.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
namespace MinecraftClient.Inventory
{
@@ -91,10 +91,11 @@ namespace MinecraftClient.Inventory
/// Container ID
/// Container Type
/// Container Title
- public Container(int id, int typeID, string title)
+ /// Protocol version for version-specific mapping
+ public Container(int id, int typeID, string title, int protocolVersion = 0)
{
ID = id;
- Type = GetContainerType(typeID);
+ Type = GetContainerType(typeID, protocolVersion);
Title = title;
Items = new();
Properties = new();
@@ -131,22 +132,62 @@ namespace MinecraftClient.Inventory
/// Get container type from Type ID
///
/// Container Type ID
+ /// Protocol version (menu registry changed across versions)
/// Container Type
- public static ContainerType GetContainerType(int typeID)
+ public static ContainerType GetContainerType(int typeID, int protocolVersion = 0)
{
- // https://wiki.vg/Inventory didn't state the inventory ID, assume that list start with 0
+ // MC 1.20.4 (protocol 765) added crafter_3x3 at index 7, shifting all subsequent IDs by +1.
+ // Registry order from decompiled MenuType.java:
+ // 1.14-1.20.2: generic_9x1..generic_3x3(6), anvil(7), beacon(8), ... stonecutter(22)
+ // 1.20.4+: generic_9x1..generic_3x3(6), crafter_3x3(7), anvil(8), beacon(9), ... stonecutter(24)
+ if (protocolVersion >= 765)
+ {
+ return typeID switch
+ {
+#pragma warning disable format // @formatter:off
+ 0 => ContainerType.Generic_9x1,
+ 1 => ContainerType.Generic_9x2,
+ 2 => ContainerType.Generic_9x3,
+ 3 => ContainerType.Generic_9x4,
+ 4 => ContainerType.Generic_9x5,
+ 5 => ContainerType.Generic_9x6,
+ 6 => ContainerType.Generic_3x3,
+ 7 => ContainerType.Crafter,
+ 8 => ContainerType.Anvil,
+ 9 => ContainerType.Beacon,
+ 10 => ContainerType.BlastFurnace,
+ 11 => ContainerType.BrewingStand,
+ 12 => ContainerType.Crafting,
+ 13 => ContainerType.Enchantment,
+ 14 => ContainerType.Furnace,
+ 15 => ContainerType.Grindstone,
+ 16 => ContainerType.Hopper,
+ 17 => ContainerType.Lectern,
+ 18 => ContainerType.Loom,
+ 19 => ContainerType.Merchant,
+ 20 => ContainerType.ShulkerBox,
+ 21 => ContainerType.SmightingTable,
+ 22 => ContainerType.Smoker,
+ 23 => ContainerType.Cartography,
+ 24 => ContainerType.Stonecutter,
+ _ => ContainerType.Unknown,
+#pragma warning restore format // @formatter:on
+ };
+ }
+
return typeID switch
{
- 0 => ContainerType.Generic_9x1,
- 1 => ContainerType.Generic_9x2,
- 2 => ContainerType.Generic_9x3,
- 3 => ContainerType.Generic_9x4,
- 4 => ContainerType.Generic_9x5,
- 5 => ContainerType.Generic_9x6,
- 6 => ContainerType.Generic_3x3,
- 7 => ContainerType.Anvil,
- 8 => ContainerType.Beacon,
- 9 => ContainerType.BlastFurnace,
+#pragma warning disable format // @formatter:off
+ 0 => ContainerType.Generic_9x1,
+ 1 => ContainerType.Generic_9x2,
+ 2 => ContainerType.Generic_9x3,
+ 3 => ContainerType.Generic_9x4,
+ 4 => ContainerType.Generic_9x5,
+ 5 => ContainerType.Generic_9x6,
+ 6 => ContainerType.Generic_3x3,
+ 7 => ContainerType.Anvil,
+ 8 => ContainerType.Beacon,
+ 9 => ContainerType.BlastFurnace,
10 => ContainerType.BrewingStand,
11 => ContainerType.Crafting,
12 => ContainerType.Enchantment,
@@ -160,7 +201,8 @@ namespace MinecraftClient.Inventory
20 => ContainerType.Smoker,
21 => ContainerType.Cartography,
22 => ContainerType.Stonecutter,
- _ => ContainerType.Unknown,
+ _ => ContainerType.Unknown,
+#pragma warning restore format // @formatter:on
};
}
diff --git a/MinecraftClient/Inventory/ContainerType.cs b/MinecraftClient/Inventory/ContainerType.cs
index 76d05416..e82878fe 100644
--- a/MinecraftClient/Inventory/ContainerType.cs
+++ b/MinecraftClient/Inventory/ContainerType.cs
@@ -1,4 +1,4 @@
-namespace MinecraftClient.Inventory
+namespace MinecraftClient.Inventory
{
// For MC 1.14 after ONLY
public enum ContainerType
@@ -10,6 +10,7 @@
Generic_9x5,
Generic_9x6,
Generic_3x3,
+ Crafter,
Anvil,
Beacon,
BlastFurnace,
diff --git a/MinecraftClient/Inventory/ContainerTypeExtensions.cs b/MinecraftClient/Inventory/ContainerTypeExtensions.cs
index 4fe16373..4644e553 100644
--- a/MinecraftClient/Inventory/ContainerTypeExtensions.cs
+++ b/MinecraftClient/Inventory/ContainerTypeExtensions.cs
@@ -1,4 +1,4 @@
-namespace MinecraftClient.Inventory
+namespace MinecraftClient.Inventory
{
public static class ContainerTypeExtensions
{
@@ -13,9 +13,14 @@
{
#pragma warning disable format // @formatter:off
ContainerType.PlayerInventory => 46,
+ ContainerType.Generic_9x1 => 45,
+ ContainerType.Generic_9x2 => 54,
ContainerType.Generic_9x3 => 63,
+ ContainerType.Generic_9x4 => 72,
+ ContainerType.Generic_9x5 => 81,
ContainerType.Generic_9x6 => 90,
ContainerType.Generic_3x3 => 45,
+ ContainerType.Crafter => 45,
ContainerType.Crafting => 46,
ContainerType.BlastFurnace => 39,
ContainerType.Furnace => 39,
@@ -27,6 +32,7 @@
ContainerType.Anvil => 39,
ContainerType.Hopper => 41,
ContainerType.ShulkerBox => 63,
+ ContainerType.SmightingTable => 39,
ContainerType.Loom => 40,
ContainerType.Stonecutter => 38,
ContainerType.Lectern => 37,
@@ -52,6 +58,7 @@
ContainerType.Generic_9x3 => AsciiArt.Container_Generic_9x3,
ContainerType.Generic_9x6 => AsciiArt.Container_Generic_9x6,
ContainerType.Generic_3x3 => AsciiArt.Container_Generic_3x3,
+ ContainerType.Crafter => AsciiArt.Container_Generic_3x3,
ContainerType.Crafting => AsciiArt.Container_Crafting,
ContainerType.BlastFurnace => AsciiArt.Container_Furnace,
ContainerType.Furnace => AsciiArt.Container_Furnace,
diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs
index e76b9b80..938a85bf 100644
--- a/MinecraftClient/McClient.cs
+++ b/MinecraftClient/McClient.cs
@@ -3124,6 +3124,13 @@ namespace MinecraftClient
Log.Info(string.Format(Translations.extra_inventory_open, inventoryID, inventory.Title));
Log.Info(Translations.extra_inventory_interact);
DispatchBotEvent(bot => bot.OnInventoryOpen(inventoryID));
+
+ if (ConsoleIO.Backend is Tui.TuiConsoleBackend
+ && Tui.ContainerViewBase.HasTuiSupport(inventory.Type)
+ && Tui.InventoryTuiHost.CanLaunch)
+ {
+ Tui.InventoryTuiHost.Launch(this, inventoryID);
+ }
}
}
@@ -3146,6 +3153,8 @@ namespace MinecraftClient
Log.Info(string.Format(Translations.extra_inventory_close, inventoryID));
DispatchBotEvent(bot => bot.OnInventoryClose(inventoryID));
}
+
+ Tui.InventoryTuiHost.NotifyInventoryClosed(inventoryID);
}
///
diff --git a/MinecraftClient/Protocol/Handlers/DataTypes.cs b/MinecraftClient/Protocol/Handlers/DataTypes.cs
index 473757c1..78456c2d 100644
--- a/MinecraftClient/Protocol/Handlers/DataTypes.cs
+++ b/MinecraftClient/Protocol/Handlers/DataTypes.cs
@@ -1715,16 +1715,15 @@ namespace MinecraftClient.Protocol.Handlers
public byte[] GetLocation(Location location)
{
byte[] locationBytes;
+ ulong x = (ulong)(int)Math.Floor(location.X) & 0x3FFFFFF;
+ ulong y = (ulong)(int)Math.Floor(location.Y) & 0xFFF;
+ ulong z = (ulong)(int)Math.Floor(location.Z) & 0x3FFFFFF;
if (protocolversion >= Protocol18Handler.MC_1_14_Version)
{
- locationBytes = BitConverter.GetBytes(((((ulong)location.X) & 0x3FFFFFF) << 38) |
- ((((ulong)location.Z) & 0x3FFFFFF) << 12) |
- (((ulong)location.Y) & 0xFFF));
+ locationBytes = BitConverter.GetBytes((x << 38) | (z << 12) | y);
}
else
- locationBytes = BitConverter.GetBytes(((((ulong)location.X) & 0x3FFFFFF) << 38) |
- ((((ulong)location.Y) & 0xFFF) << 26) |
- (((ulong)location.Z) & 0x3FFFFFF));
+ locationBytes = BitConverter.GetBytes((x << 38) | (y << 26) | z);
Array.Reverse(locationBytes); //Endianness
return locationBytes;
diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs
index 48568f0f..b6cdcd05 100644
--- a/MinecraftClient/Protocol/Handlers/Protocol18.cs
+++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs
@@ -2319,7 +2319,7 @@ namespace MinecraftClient.Protocol.Handlers
var windowId = dataTypes.ReadNextVarInt(packetData);
var windowType = dataTypes.ReadNextVarInt(packetData);
var title = dataTypes.ReadNextChat(packetData);
- Container inventory = new(windowId, windowType, ChatParser.ParseText(title));
+ Container inventory = new(windowId, windowType, ChatParser.ParseText(title), protocolVersion);
handler.OnInventoryOpen(windowId, inventory);
}
}
diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs
index cce4eda2..3a2c4bc9 100644
--- a/MinecraftClient/Resources/Translations/Translations.Designer.cs
+++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs
@@ -6530,6 +6530,15 @@ namespace MinecraftClient {
}
}
+ ///
+ /// Looks up a localized string similar to Durability.
+ ///
+ internal static string tui_inventory_durability {
+ get {
+ return ResourceManager.GetString("tui.inventory.durability", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Container not found.
///
@@ -6664,5 +6673,131 @@ namespace MinecraftClient {
return ResourceManager.GetString("effect.name.with_amplifier", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Container.
+ ///
+ internal static string tui_container_label {
+ get {
+ return ResourceManager.GetString("tui.container.label", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Input.
+ ///
+ internal static string tui_furnace_input {
+ get {
+ return ResourceManager.GetString("tui.furnace.input", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Fuel.
+ ///
+ internal static string tui_furnace_fuel {
+ get {
+ return ResourceManager.GetString("tui.furnace.fuel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Output.
+ ///
+ internal static string tui_furnace_output {
+ get {
+ return ResourceManager.GetString("tui.furnace.output", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Item.
+ ///
+ internal static string tui_enchanting_item {
+ get {
+ return ResourceManager.GetString("tui.enchanting.item", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Lapis.
+ ///
+ internal static string tui_enchanting_lapis {
+ get {
+ return ResourceManager.GetString("tui.enchanting.lapis", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Enchant Options.
+ ///
+ internal static string tui_enchanting_options {
+ get {
+ return ResourceManager.GetString("tui.enchanting.options", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Option {0}.
+ ///
+ internal static string tui_enchanting_option_slot {
+ get {
+ return ResourceManager.GetString("tui.enchanting.option_slot", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Fuel.
+ ///
+ internal static string tui_brewing_fuel {
+ get {
+ return ResourceManager.GetString("tui.brewing.fuel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Ingredient.
+ ///
+ internal static string tui_brewing_ingredient {
+ get {
+ return ResourceManager.GetString("tui.brewing.ingredient", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Bottle {0}.
+ ///
+ internal static string tui_brewing_bottle {
+ get {
+ return ResourceManager.GetString("tui.brewing.bottle", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Input 1.
+ ///
+ internal static string tui_grindstone_input1 {
+ get {
+ return ResourceManager.GetString("tui.grindstone.input1", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Input 2.
+ ///
+ internal static string tui_grindstone_input2 {
+ get {
+ return ResourceManager.GetString("tui.grindstone.input2", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Crafting.
+ ///
+ internal static string tui_crafting_grid {
+ get {
+ return ResourceManager.GetString("tui.crafting.grid", resourceCulture);
+ }
+ }
}
}
diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx
index 45662bd5..68202894 100644
--- a/MinecraftClient/Resources/Translations/Translations.resx
+++ b/MinecraftClient/Resources/Translations/Translations.resx
@@ -2302,6 +2302,9 @@ see item details.
Slot #{0} Count: {1}
+
+ Durability
+
Container not found
@@ -2347,4 +2350,46 @@ see item details.
{0} {1}
+
+ Container
+
+
+ Input
+
+
+ Fuel
+
+
+ Output
+
+
+ Item
+
+
+ Lapis
+
+
+ Enchant Options
+
+
+ Option {0}
+
+
+ Fuel
+
+
+ Ingredient
+
+
+ Bottle {0}
+
+
+ Input 1
+
+
+ Input 2
+
+
+ Crafting
+
\ No newline at end of file
diff --git a/MinecraftClient/Tui/BrewingStandView.cs b/MinecraftClient/Tui/BrewingStandView.cs
new file mode 100644
index 00000000..6a00071b
--- /dev/null
+++ b/MinecraftClient/Tui/BrewingStandView.cs
@@ -0,0 +1,152 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MinecraftClient.Inventory;
+
+namespace MinecraftClient.Tui
+{
+ public class BrewingStandView : ContainerViewBase
+ {
+ private readonly BrewingViewModel _brewVm;
+
+ public BrewingStandView(McClient handler, int windowId)
+ : base(new BrewingViewModel(handler, windowId))
+ {
+ _brewVm = (BrewingViewModel)_vm;
+ Initialize();
+ }
+
+ protected override int GetTotalSlotRows()
+ {
+ return 3 + 3 + 1;
+ }
+
+ protected override Control BuildContainerSpecificArea()
+ {
+ var panel = new StackPanel
+ {
+ Spacing = 0,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
+ var topRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Spacing = 0,
+ };
+
+ var fuelCol = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ fuelCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_brewing_fuel,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ fuelCol.Children.Add(CreateSlotCell(_brewVm.FuelSlot, 0, 0));
+ topRow.Children.Add(fuelCol);
+
+ topRow.Children.Add(new Border { Width = 2 });
+
+ var ingredientCol = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ ingredientCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_brewing_ingredient,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ ingredientCol.Children.Add(CreateSlotCell(_brewVm.IngredientSlot, 0, 1));
+ topRow.Children.Add(ingredientCol);
+
+ panel.Children.Add(topRow);
+
+ panel.Children.Add(new TextBlock
+ {
+ Text = "\u25bc",
+ Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+
+ var bottleRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Spacing = 0,
+ };
+ for (int i = 0; i < 3; i++)
+ {
+ var bottlePanel = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ bottlePanel.Children.Add(new TextBlock
+ {
+ Text = string.Format(Translations.tui_brewing_bottle, i + 1),
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ bottlePanel.Children.Add(CreateSlotCell(_brewVm.BottleSlots[i], 1, i));
+ bottleRow.Children.Add(bottlePanel);
+ }
+
+ panel.Children.Add(bottleRow);
+
+ return panel;
+ }
+ }
+
+ public class BrewingViewModel : ContainerViewModel
+ {
+ public ObservableCollection BottleSlots { get; } = new();
+ public SlotViewModel IngredientSlot { get; private set; } = null!;
+ public SlotViewModel FuelSlot { get; private set; } = null!;
+
+ public BrewingViewModel(McClient handler, int windowId)
+ : base(handler, windowId, ContainerType.BrewingStand)
+ {
+ IngredientSlot = SlotMap[3];
+ FuelSlot = SlotMap[4];
+ }
+
+ protected override void InitializeSlots()
+ {
+ SlotMap.Clear();
+
+ for (int i = 0; i <= 2; i++)
+ {
+ var slot = new SlotViewModel(i);
+ BottleSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ SlotMap[3] = new SlotViewModel(3);
+ SlotMap[4] = new SlotViewModel(4);
+
+ for (int i = 5; i <= 31; i++)
+ {
+ var slot = new SlotViewModel(i);
+ MainInventorySlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ for (int i = 32; i <= 40; i++)
+ {
+ int hotbarIdx = i - 32;
+ var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
+ HotbarSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/ContainerViewBase.cs b/MinecraftClient/Tui/ContainerViewBase.cs
new file mode 100644
index 00000000..2a5b7668
--- /dev/null
+++ b/MinecraftClient/Tui/ContainerViewBase.cs
@@ -0,0 +1,730 @@
+using System;
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MinecraftClient.Inventory;
+
+namespace MinecraftClient.Tui
+{
+ public abstract class ContainerViewBase : UserControl
+ {
+ protected static readonly IBrush BrSlotEmptyA = new SolidColorBrush(Color.FromRgb(40, 40, 40));
+ protected static readonly IBrush BrSlotEmptyB = new SolidColorBrush(Color.FromRgb(55, 55, 55));
+ protected static readonly IBrush BrSlotFillA = new SolidColorBrush(Color.FromRgb(60, 60, 75));
+ protected static readonly IBrush BrSlotFillB = new SolidColorBrush(Color.FromRgb(75, 75, 90));
+ protected static readonly IBrush BrSlotHover = new SolidColorBrush(Color.FromRgb(100, 100, 140));
+ protected static readonly IBrush BrName = Brushes.White;
+ protected static readonly IBrush BrCount = Brushes.Yellow;
+ protected static readonly IBrush BrDim = new SolidColorBrush(Color.FromRgb(80, 80, 80));
+ protected static readonly IBrush BrEquipLbl = Brushes.DarkCyan;
+ protected static readonly IBrush BrInfoHighlight = new SolidColorBrush(Color.FromRgb(40, 40, 60));
+ protected static readonly IBrush BrHeldItemBg = new SolidColorBrush(Color.FromRgb(60, 50, 80));
+ protected static readonly IBrush BrHeldItemBorder = Brushes.Yellow;
+
+ protected int _slotW;
+ protected int _slotH;
+ protected int _nameMaxLen;
+ protected int _nameLines;
+ protected int _termW;
+
+ protected readonly ContainerViewModel _vm;
+ protected TextBlock _titleText = null!;
+ protected Border _infoDetailBorder = null!;
+ protected TextBlock _infoDetailText = null!;
+ protected TextBlock _cursorItemText = null!;
+ protected TextBlock _helpText = null!;
+
+ protected TextBlock[] _hotbarIndicators = new TextBlock[9];
+ protected int _currentHotbarSlot = -1;
+
+ protected Border? _lastHoveredSlotBorder;
+
+ protected Canvas _overlayCanvas = null!;
+ protected Border _heldItemFloater = null!;
+ protected TextBlock _heldItemFloaterName = null!;
+ protected TextBlock _heldItemFloaterCount = null!;
+
+ protected ScrollViewer _chatScrollViewer = null!;
+ protected ObservableCollection? _chatLines;
+ protected int _lastTermW;
+ protected int _lastTermH;
+ protected bool _chatScrollToBottom = true;
+
+ protected ContainerViewBase(ContainerViewModel vm)
+ {
+ _vm = vm;
+ _currentHotbarSlot = vm.Handler.GetCurrentSlot();
+
+ _chatLines = TuiConsoleBackend.Instance?.GetView()?.GetRecentLogLines(50)
+ ?? new ObservableCollection();
+ }
+
+ protected void Initialize()
+ {
+ RebuildUi();
+ }
+
+ protected abstract int GetTotalSlotRows();
+
+ protected abstract Control BuildContainerSpecificArea();
+
+ protected virtual void OnContainerDataChanged() { }
+
+ protected virtual void RebuildUi()
+ {
+ int termH;
+ try
+ {
+ _termW = System.Console.WindowWidth;
+ termH = System.Console.WindowHeight;
+ }
+ catch
+ {
+ _termW = 120;
+ termH = 40;
+ }
+
+ _lastTermW = _termW;
+ _lastTermH = termH;
+
+ int availW = _termW - 26;
+ _slotW = Math.Clamp(availW / 9, 8, 18);
+ _nameMaxLen = _slotW;
+
+ int totalRows = GetTotalSlotRows();
+ int overhead = 4;
+ int chatMinH = 1;
+ _slotH = Math.Clamp((termH - overhead - chatMinH) / totalRows, 2, 5);
+ _nameLines = _slotH;
+
+ _vm.SetSlotDisplayParams(_nameMaxLen, _nameLines);
+
+ _lastHoveredSlotBorder = null;
+
+ _titleText = new TextBlock
+ {
+ FontWeight = FontWeight.Bold,
+ Foreground = Brushes.Cyan,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
+ _infoDetailText = new TextBlock
+ {
+ TextWrapping = TextWrapping.Wrap,
+ Foreground = Brushes.White,
+ };
+
+ _infoDetailBorder = new Border
+ {
+ Background = Brushes.Transparent,
+ Padding = new Thickness(0),
+ Child = _infoDetailText,
+ };
+
+ _cursorItemText = new TextBlock
+ {
+ Foreground = Brushes.Yellow,
+ FontWeight = FontWeight.Bold,
+ TextWrapping = TextWrapping.Wrap,
+ };
+
+ _helpText = new TextBlock
+ {
+ TextWrapping = TextWrapping.Wrap,
+ Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)),
+ Text = Translations.tui_inventory_controls_help,
+ };
+
+ _heldItemFloaterName = new TextBlock
+ {
+ Foreground = Brushes.White,
+ FontWeight = FontWeight.Bold,
+ TextWrapping = TextWrapping.Wrap,
+ };
+ _heldItemFloaterCount = new TextBlock
+ {
+ Foreground = BrCount,
+ FontWeight = FontWeight.Bold,
+ };
+ _heldItemFloater = new Border
+ {
+ Background = BrHeldItemBg,
+ BorderBrush = BrHeldItemBorder,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(1, 0),
+ IsVisible = false,
+ MaxWidth = 24,
+ Child = new StackPanel
+ {
+ Children = { _heldItemFloaterName, _heldItemFloaterCount },
+ },
+ };
+
+ _overlayCanvas = new Canvas { IsHitTestVisible = false };
+ _overlayCanvas.Children.Add(_heldItemFloater);
+
+ var chatLines = _chatLines!;
+ chatLines.CollectionChanged += (_, _) =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ var sv = _chatScrollViewer;
+ if (sv.Extent.Height > sv.Viewport.Height)
+ sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height);
+ }, Avalonia.Threading.DispatcherPriority.Background);
+ };
+ var chatItemsControl = new ItemsControl
+ {
+ ItemsSource = chatLines,
+ Focusable = false,
+ ItemTemplate = new FuncDataTemplate((s, _) =>
+ new TextBlock
+ {
+ Text = s,
+ Foreground = Brushes.Gray,
+ Padding = new Thickness(0),
+ Margin = new Thickness(0),
+ TextWrapping = TextWrapping.Wrap,
+ }),
+ };
+ _chatScrollViewer = new ScrollViewer
+ {
+ Content = chatItemsControl,
+ Background = Brushes.Black,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
+ Padding = new Thickness(0),
+ };
+
+ _hotbarIndicators = new TextBlock[9];
+
+ Content = BuildRootLayout();
+ UpdateTitle();
+ UpdateInfoPanel();
+
+ _chatScrollToBottom = true;
+ _chatScrollViewer.ScrollChanged += OnChatScrollChanged;
+ }
+
+ private void OnChatScrollChanged(object? sender, ScrollChangedEventArgs e)
+ {
+ if (!_chatScrollToBottom) return;
+ var sv = _chatScrollViewer;
+ if (sv.Extent.Height > sv.Viewport.Height)
+ {
+ sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height);
+ _chatScrollToBottom = false;
+ }
+ }
+
+ protected virtual Control BuildRootLayout()
+ {
+ var inventoryArea = BuildMainArea();
+ DockPanel.SetDock(_titleText, Dock.Top);
+ DockPanel.SetDock(inventoryArea, Dock.Top);
+
+ var mainContent = new DockPanel
+ {
+ Children = { _titleText, inventoryArea, _chatScrollViewer }
+ };
+
+ return new Panel
+ {
+ Background = Brushes.Black,
+ Children = { mainContent, _overlayCanvas }
+ };
+ }
+
+ protected virtual Control BuildMainArea()
+ {
+ var infoPanel = BuildInfoPanel();
+ DockPanel.SetDock(infoPanel, Dock.Right);
+
+ return new DockPanel
+ {
+ Children = { infoPanel, BuildInventoryPanel() }
+ };
+ }
+
+ protected virtual Control BuildInventoryPanel()
+ {
+ var root = new StackPanel
+ {
+ Spacing = 0,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
+ root.Children.Add(BuildContainerSpecificArea());
+ root.Children.Add(BuildSeparator());
+ root.Children.Add(BuildSlotGrid(_vm.MainInventorySlots, 9));
+ root.Children.Add(BuildHotbarSection());
+
+ return new Border
+ {
+ BorderThickness = new Thickness(1),
+ BorderBrush = Brushes.Gray,
+ Child = root,
+ };
+ }
+
+ protected Control BuildSeparator()
+ {
+ return new Border
+ {
+ Height = 1,
+ Background = Brushes.Transparent,
+ Margin = new Thickness(0, 0, 0, 0),
+ };
+ }
+
+ protected Control BuildInfoPanel()
+ {
+ return new Border
+ {
+ BorderThickness = new Thickness(1),
+ BorderBrush = Brushes.Gray,
+ Padding = new Thickness(1),
+ Width = 24,
+ Child = new StackPanel
+ {
+ Children =
+ {
+ new TextBlock { Text = Translations.tui_inventory_item_info, FontWeight = FontWeight.Bold, Foreground = Brushes.Cyan },
+ _infoDetailBorder,
+ new TextBlock { Text = Translations.tui_inventory_held_item, FontWeight = FontWeight.Bold, Foreground = Brushes.Yellow, Margin = new Thickness(0, 1, 0, 0) },
+ _cursorItemText,
+ new TextBlock { Text = Translations.tui_inventory_controls, FontWeight = FontWeight.Bold, Foreground = Brushes.Green, Margin = new Thickness(0, 1, 0, 0) },
+ _helpText,
+ }
+ }
+ };
+ }
+
+ protected Control BuildHotbarSection()
+ {
+ var panel = new StackPanel { Spacing = 0 };
+
+ var numberRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+ for (int i = 0; i < 9; i++)
+ {
+ bool active = i == _currentHotbarSlot;
+ string label = active ? $"{i + 1} \u25bc" : $" {i + 1} ";
+
+ var tb = new TextBlock
+ {
+ Text = label,
+ Width = _slotW,
+ TextAlignment = TextAlignment.Center,
+ Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan,
+ FontWeight = FontWeight.Bold,
+ };
+ _hotbarIndicators[i] = tb;
+ numberRow.Children.Add(tb);
+ }
+ panel.Children.Add(numberRow);
+ panel.Children.Add(BuildSlotGrid(_vm.HotbarSlots, 9));
+ return panel;
+ }
+
+ protected Control BuildSlotGrid(ObservableCollection slots, int columns)
+ {
+ var grid = new Grid();
+ int rows = (slots.Count + columns - 1) / columns;
+
+ for (int r = 0; r < rows; r++)
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ for (int c = 0; c < columns; c++)
+ grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
+
+ for (int i = 0; i < slots.Count; i++)
+ {
+ int row = i / columns;
+ int col = i % columns;
+ var cell = CreateSlotCell(slots[i], row, col);
+ Grid.SetRow(cell, row);
+ Grid.SetColumn(cell, col);
+ grid.Children.Add(cell);
+ }
+
+ return grid;
+ }
+
+ protected static IBrush GetSlotBg(bool isEmpty, int row, int col)
+ {
+ bool isA = (row + col) % 2 == 0;
+ return isEmpty
+ ? (isA ? BrSlotEmptyA : BrSlotEmptyB)
+ : (isA ? BrSlotFillA : BrSlotFillB);
+ }
+
+ protected Border CreateSlotCell(SlotViewModel slot, int row = 0, int col = 0)
+ {
+ var nameTb = new TextBlock
+ {
+ TextWrapping = TextWrapping.Wrap,
+ Padding = new Thickness(0),
+ Margin = new Thickness(0),
+ VerticalAlignment = VerticalAlignment.Top,
+ };
+
+ var countTb = new TextBlock
+ {
+ Foreground = BrCount,
+ FontWeight = FontWeight.Bold,
+ Padding = new Thickness(0),
+ Margin = new Thickness(0),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ };
+
+ ApplySlotVisual(slot, nameTb, countTb);
+
+ int r = row, c = col;
+ var border = new Border
+ {
+ Width = _slotW,
+ Height = _slotH,
+ Background = GetSlotBg(slot.IsEmpty, r, c),
+ Child = new Panel
+ {
+ Children = { nameTb, countTb },
+ },
+ Tag = (slot, r, c),
+ };
+
+ border.PointerPressed += OnSlotPointerPressed;
+ border.PointerEntered += OnSlotPointerEnter;
+ border.PointerExited += OnSlotPointerExit;
+ border.PointerMoved += OnSlotPointerMoved;
+
+ slot.PropertyChanged += (_, _) =>
+ {
+ ApplySlotVisual(slot, nameTb, countTb);
+ border.Background = GetSlotBg(slot.IsEmpty, r, c);
+ };
+
+ return border;
+ }
+
+ protected static void ApplySlotVisual(SlotViewModel slot, TextBlock nameTb, TextBlock countTb)
+ {
+ if (slot.IsEmpty)
+ {
+ nameTb.Text = "";
+ nameTb.Foreground = BrDim;
+ countTb.Text = "";
+ }
+ else
+ {
+ nameTb.Text = slot.ItemDisplayText;
+ nameTb.Foreground = BrName;
+ countTb.Text = slot.CountDisplay;
+ }
+ }
+
+ protected TextBlock MakeLabel(string text)
+ {
+ return new TextBlock
+ {
+ Text = text,
+ Foreground = BrEquipLbl,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(1, 0, 0, 0),
+ FontWeight = FontWeight.Bold,
+ };
+ }
+
+ #region Pointer / Keyboard interaction
+
+ private void OnSlotPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not Border border || border.Tag is not (SlotViewModel slot, int, int))
+ return;
+
+ SetHover(border, slot);
+
+ var point = e.GetCurrentPoint(border);
+ bool isShift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
+
+ WindowActionType action;
+ if (point.Properties.IsRightButtonPressed)
+ action = isShift ? WindowActionType.ShiftRightClick : WindowActionType.RightClick;
+ else
+ action = isShift ? WindowActionType.ShiftClick : WindowActionType.LeftClick;
+
+ _vm.PerformAction(slot.SlotId, action);
+ UpdateInfoPanel();
+ UpdateHeldItemFloater(e);
+ OnContainerDataChanged();
+ e.Handled = true;
+ }
+
+ private void OnSlotPointerEnter(object? sender, PointerEventArgs e)
+ {
+ if (sender is Border b && b.Tag is (SlotViewModel slot, int, int))
+ {
+ SetHover(b, slot);
+ UpdateHeldItemFloater(e);
+ }
+ }
+
+ private void OnSlotPointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (sender is Border b && b.Tag is (SlotViewModel slot, int, int))
+ {
+ SetHover(b, slot);
+ UpdateHeldItemFloater(e);
+ }
+ }
+
+ private void OnSlotPointerExit(object? sender, PointerEventArgs e)
+ {
+ if (sender is Border b && b.Tag is (SlotViewModel slot, int row, int col))
+ b.Background = GetSlotBg(slot.IsEmpty, row, col);
+ }
+
+ protected void SetHover(Border border, SlotViewModel slot)
+ {
+ if (_lastHoveredSlotBorder != null && _lastHoveredSlotBorder != border)
+ {
+ if (_lastHoveredSlotBorder.Tag is (SlotViewModel oldSlot, int or, int oc))
+ _lastHoveredSlotBorder.Background = GetSlotBg(oldSlot.IsEmpty, or, oc);
+ }
+
+ _lastHoveredSlotBorder = border;
+ border.Background = BrSlotHover;
+ _vm.HoveredSlot = slot;
+ UpdateInfoPanel();
+ }
+
+ protected void UpdateHeldItemFloater(PointerEventArgs e)
+ {
+ if (!_vm.HasCursorItem)
+ {
+ _heldItemFloater.IsVisible = false;
+ return;
+ }
+
+ _heldItemFloaterName.Text = _vm.CursorItemInfo;
+ _heldItemFloaterCount.Text = "";
+
+ try
+ {
+ var pos = e.GetPosition(_overlayCanvas);
+ double left = pos.X + 2;
+ double remainingW = _termW - left - 2;
+ int maxW = Math.Max(8, (int)remainingW);
+ _heldItemFloater.MaxWidth = maxW;
+ Canvas.SetLeft(_heldItemFloater, left);
+ Canvas.SetTop(_heldItemFloater, pos.Y);
+ }
+ catch
+ {
+ _heldItemFloater.MaxWidth = 24;
+ Canvas.SetLeft(_heldItemFloater, 0);
+ Canvas.SetTop(_heldItemFloater, 0);
+ }
+
+ _heldItemFloater.IsVisible = true;
+ }
+
+ protected void UpdateInfoPanel()
+ {
+ _infoDetailText.Text = _vm.HoveredSlotDetailText;
+
+ bool hasHoveredItem = _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty;
+ _infoDetailBorder.Background = hasHoveredItem ? BrInfoHighlight : Brushes.Transparent;
+
+ if (_vm.HasCursorItem)
+ {
+ _cursorItemText.Text = _vm.CursorItemInfo;
+ _cursorItemText.Foreground = Brushes.Yellow;
+ }
+ else
+ {
+ _cursorItemText.Text = Translations.tui_inventory_cursor_empty;
+ _cursorItemText.Foreground = BrDim;
+ _heldItemFloater.IsVisible = false;
+ }
+ }
+
+ protected void UpdateTitle()
+ {
+ _titleText.Text = _vm.Title;
+ }
+
+ protected void CloseInventory()
+ {
+ if (_vm.WindowId != 0)
+ _vm.Handler.CloseInventory(_vm.WindowId);
+
+ if (ConsoleIO.Backend is TuiConsoleBackend tuiBackend)
+ tuiBackend.GetView()?.HideOverlay();
+ else
+ (Application.Current?.ApplicationLifetime as IControlledApplicationLifetime)?.Shutdown();
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+
+ switch (e.Key)
+ {
+ case Key.Escape:
+ case Key.E:
+ CloseInventory();
+ e.Handled = true;
+ break;
+
+ case Key.C:
+ if ((e.KeyModifiers & KeyModifiers.Shift) != 0 &&
+ _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty)
+ {
+ _vm.PerformAction(_vm.HoveredSlot.SlotId, WindowActionType.ShiftClick);
+ UpdateInfoPanel();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Q:
+ if (_vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty)
+ {
+ var action = (e.KeyModifiers & KeyModifiers.Control) != 0
+ ? WindowActionType.DropItemStack
+ : WindowActionType.DropItem;
+ _vm.PerformAction(_vm.HoveredSlot.SlotId, action);
+ UpdateInfoPanel();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.R:
+ _vm.RefreshFromContainer();
+ _currentHotbarSlot = _vm.Handler.GetCurrentSlot();
+ UpdateHotbarIndicators();
+ UpdateInfoPanel();
+ OnContainerDataChanged();
+ e.Handled = true;
+ break;
+ }
+ }
+
+ protected void UpdateHotbarIndicators()
+ {
+ for (int i = 0; i < 9; i++)
+ {
+ bool active = i == _currentHotbarSlot;
+ _hotbarIndicators[i].Text = active ? $"{i + 1} \u25bc" : $" {i + 1} ";
+ _hotbarIndicators[i].Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan;
+ }
+ }
+
+ #endregion
+
+ #region Lifecycle
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ Focusable = true;
+ Focus();
+ AddHandler(KeyDownEvent, OnTunnelKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ SizeChanged += OnViewSizeChanged;
+ }
+
+ private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Escape)
+ {
+ CloseInventory();
+ e.Handled = true;
+ }
+ }
+
+ private void OnViewSizeChanged(object? sender, SizeChangedEventArgs e)
+ {
+ int newW, newH;
+ try
+ {
+ newW = System.Console.WindowWidth;
+ newH = System.Console.WindowHeight;
+ }
+ catch { return; }
+
+ if (newW == _lastTermW && newH == _lastTermH) return;
+
+ _vm.RefreshFromContainer();
+ _currentHotbarSlot = _vm.Handler.GetCurrentSlot();
+ RebuildUi();
+ Focus();
+ }
+
+ protected override void OnGotFocus(GotFocusEventArgs e)
+ {
+ base.OnGotFocus(e);
+ Focusable = true;
+ }
+
+ #endregion
+
+ public static bool HasTuiSupport(ContainerType type)
+ {
+ return type switch
+ {
+ ContainerType.PlayerInventory => true,
+ ContainerType.Generic_9x1 => true,
+ ContainerType.Generic_9x2 => true,
+ ContainerType.Generic_9x3 => true,
+ ContainerType.Generic_9x4 => true,
+ ContainerType.Generic_9x5 => true,
+ ContainerType.Generic_9x6 => true,
+ ContainerType.Generic_3x3 => true,
+ ContainerType.Crafter => true,
+ ContainerType.ShulkerBox => true,
+ ContainerType.Crafting => true,
+ ContainerType.Furnace => true,
+ ContainerType.BlastFurnace => true,
+ ContainerType.Smoker => true,
+ ContainerType.Enchantment => true,
+ ContainerType.BrewingStand => true,
+ ContainerType.Hopper => true,
+ ContainerType.Grindstone => true,
+ _ => false,
+ };
+ }
+
+ public static ContainerViewBase CreateView(ContainerType type, McClient handler, int windowId)
+ {
+ return type switch
+ {
+ ContainerType.PlayerInventory => new PlayerInventoryView(handler, windowId),
+ ContainerType.Generic_9x3 or ContainerType.ShulkerBox => new GridContainerView(handler, windowId, type, 3, 9),
+ ContainerType.Generic_9x6 => new GridContainerView(handler, windowId, type, 6, 9),
+ ContainerType.Generic_3x3 or ContainerType.Crafter
+ => new GridContainerView(handler, windowId, type, 3, 3),
+ ContainerType.Generic_9x1 => new GridContainerView(handler, windowId, type, 1, 9),
+ ContainerType.Generic_9x2 => new GridContainerView(handler, windowId, type, 2, 9),
+ ContainerType.Generic_9x4 => new GridContainerView(handler, windowId, type, 4, 9),
+ ContainerType.Generic_9x5 => new GridContainerView(handler, windowId, type, 5, 9),
+ ContainerType.Crafting => new CraftingView(handler, windowId),
+ ContainerType.Furnace or ContainerType.BlastFurnace or ContainerType.Smoker
+ => new FurnaceView(handler, windowId, type),
+ ContainerType.Enchantment => new EnchantingTableView(handler, windowId),
+ ContainerType.BrewingStand => new BrewingStandView(handler, windowId),
+ ContainerType.Hopper => new HopperView(handler, windowId),
+ ContainerType.Grindstone => new GrindstoneView(handler, windowId),
+ _ => throw new ArgumentException($"No TUI view for {type}"),
+ };
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/ContainerViewModel.cs b/MinecraftClient/Tui/ContainerViewModel.cs
new file mode 100644
index 00000000..1effcdae
--- /dev/null
+++ b/MinecraftClient/Tui/ContainerViewModel.cs
@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using MinecraftClient.Inventory;
+using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6;
+
+namespace MinecraftClient.Tui
+{
+ public class ContainerViewModel : INotifyPropertyChanged
+ {
+ private SlotViewModel? _hoveredSlot;
+ private string _title = "";
+ private string _statusText = "";
+ private string _cursorItemInfo = "";
+ private bool _hasCursorItem;
+
+ public McClient Handler { get; }
+ public int WindowId { get; }
+ public ContainerType ContainerType { get; }
+
+ public ObservableCollection ContainerSlots { get; } = new();
+ public ObservableCollection MainInventorySlots { get; } = new();
+ public ObservableCollection HotbarSlots { get; } = new();
+
+ public string Title
+ {
+ get => _title;
+ set { _title = value; OnPropertyChanged(); }
+ }
+
+ public string StatusText
+ {
+ get => _statusText;
+ set { _statusText = value; OnPropertyChanged(); }
+ }
+
+ public string CursorItemInfo
+ {
+ get => _cursorItemInfo;
+ set { _cursorItemInfo = value; OnPropertyChanged(); }
+ }
+
+ public bool HasCursorItem
+ {
+ get => _hasCursorItem;
+ set { _hasCursorItem = value; OnPropertyChanged(); }
+ }
+
+ public SlotViewModel? HoveredSlot
+ {
+ get => _hoveredSlot;
+ set
+ {
+ if (_hoveredSlot != null)
+ _hoveredSlot.IsHovered = false;
+ _hoveredSlot = value;
+ if (_hoveredSlot != null)
+ _hoveredSlot.IsHovered = true;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(HoveredSlotDetailText));
+ }
+ }
+
+ public string HoveredSlotDetailText
+ {
+ get
+ {
+ if (_hoveredSlot == null)
+ return Translations.tui_inventory_hover_hint;
+
+ if (_hoveredSlot.IsEmpty)
+ return $"Slot #{_hoveredSlot.SlotId}\n{Translations.tui_inventory_slot_empty}";
+
+ var sb = new StringBuilder();
+ sb.AppendLine(_hoveredSlot.ItemTypeName);
+ sb.AppendLine(string.Format(Translations.tui_inventory_slot_detail, _hoveredSlot.SlotId, _hoveredSlot.ItemCount));
+
+ var item = _hoveredSlot.RawItem;
+ if (item != null)
+ AppendItemExtras(sb, item);
+
+ return sb.ToString().TrimEnd();
+ }
+ }
+
+ protected Dictionary SlotMap { get; } = new();
+
+ public ContainerViewModel(McClient handler, int windowId, ContainerType containerType)
+ {
+ Handler = handler;
+ WindowId = windowId;
+ ContainerType = containerType;
+
+ InitializeSlots();
+ RefreshFromContainer();
+ }
+
+ public void SetSlotDisplayParams(int maxWidth, int maxLines)
+ {
+ foreach (var kvp in SlotMap)
+ {
+ kvp.Value.NameMaxWidth = maxWidth;
+ kvp.Value.NameMaxLines = maxLines;
+ }
+ RefreshFromContainer();
+ }
+
+ protected virtual void InitializeSlots()
+ {
+ SlotMap.Clear();
+
+ int slotCount = ContainerType.SlotCount();
+ if (slotCount == 0) return;
+
+ int playerInvStart = slotCount - 36;
+
+ for (int i = 0; i < playerInvStart; i++)
+ {
+ var slot = new SlotViewModel(i);
+ ContainerSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ for (int i = playerInvStart; i < playerInvStart + 27; i++)
+ {
+ var slot = new SlotViewModel(i);
+ MainInventorySlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ for (int i = playerInvStart + 27; i < slotCount; i++)
+ {
+ int hotbarIdx = i - (playerInvStart + 27);
+ var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
+ HotbarSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+ }
+
+ public virtual void RefreshFromContainer()
+ {
+ Inventory.Container? container = Handler.GetInventory(WindowId);
+ if (container == null)
+ {
+ StatusText = Translations.tui_inventory_container_not_found;
+ return;
+ }
+
+ Title = string.Format(Translations.tui_inventory_title, WindowId, container.Title);
+
+ foreach (var kvp in SlotMap)
+ {
+ Item? item = container.Items.TryGetValue(kvp.Key, out var it) ? it : null;
+ kvp.Value.Update(item);
+ }
+
+ UpdateCursorItem(container);
+ int itemCount = 0;
+ foreach (var kvp in container.Items)
+ {
+ if (kvp.Key >= 0 && !kvp.Value.IsEmpty)
+ itemCount++;
+ }
+ StatusText = string.Format(Translations.tui_inventory_item_count, itemCount);
+
+ OnPropertyChanged(nameof(HoveredSlotDetailText));
+ }
+
+ protected void UpdateCursorItem(Inventory.Container _)
+ {
+ var playerInv = Handler.GetInventory(0);
+ if (playerInv != null && playerInv.Items.TryGetValue(-1, out var cursorItem) && !cursorItem.IsEmpty)
+ {
+ CursorItemInfo = FormatItemDetail(cursorItem);
+ HasCursorItem = true;
+ }
+ else
+ {
+ CursorItemInfo = "";
+ HasCursorItem = false;
+ }
+ }
+
+ protected static string FormatItemDetail(Item item)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"x{item.Count} {item.GetTypeString()}");
+ AppendItemExtras(sb, item);
+ if (sb.Length > 0 && sb[sb.Length - 1] == '\n')
+ sb.Remove(sb.Length - 1, 1);
+ return sb.ToString();
+ }
+
+ private static void AppendItemExtras(StringBuilder sb, Item item)
+ {
+ int damage = item.Damage;
+ if (damage != 0)
+ {
+ int maxDamage = item.Components?.OfType().FirstOrDefault()?.MaxDamage ?? 0;
+ if (maxDamage > 0)
+ sb.AppendLine($"{Translations.tui_inventory_durability}: {maxDamage - damage}/{maxDamage}");
+ else
+ sb.AppendLine($"{Translations.cmd_inventory_damage}: {damage}");
+ }
+
+ try
+ {
+ var enchList = item.EnchantmentList;
+ if (enchList is not null)
+ {
+ bool isFirstEnchantment = true;
+ foreach (var ench in enchList)
+ {
+ string name = EnchantmentMapping.GetEnchantmentName(ench.Type);
+ string level = EnchantmentMapping.ConvertLevelToRomanNumbers(ench.Level);
+ if (isFirstEnchantment)
+ {
+ isFirstEnchantment = false;
+ sb.Append($"{name} {level}");
+ }
+ else
+ {
+ sb.Append($" | {name} {level}");
+ }
+ }
+ }
+ else if (item.NBT is not null &&
+ (item.NBT.TryGetValue("Enchantments", out object? enchantments) ||
+ item.NBT.TryGetValue("StoredEnchantments", out enchantments)))
+ {
+ bool isFirstEnchantment = true;
+ foreach (Dictionary enchantment in (object[])enchantments)
+ {
+ short level = (short)enchantment["lvl"];
+ string id = ((string)enchantment["id"]).Replace(':', '.');
+ string name = Protocol.Message.ChatParser.TranslateString("enchantment." + id) ?? id;
+ string levelStr = Protocol.Message.ChatParser.TranslateString("enchantment.level." + level) ?? level.ToString();
+ if (isFirstEnchantment)
+ {
+ isFirstEnchantment = false;
+ sb.Append($"{name} {levelStr}");
+ }
+ else
+ {
+ sb.Append($" | {name} {levelStr}");
+ }
+ }
+ }
+ }
+ catch { }
+ }
+
+ public bool PerformAction(int slotId, WindowActionType action)
+ {
+ bool result = Handler.DoWindowAction(WindowId, slotId, action);
+ RefreshFromContainer();
+ return result;
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected void OnPropertyChanged([CallerMemberName] string? name = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/CraftingView.cs b/MinecraftClient/Tui/CraftingView.cs
new file mode 100644
index 00000000..dbbfc508
--- /dev/null
+++ b/MinecraftClient/Tui/CraftingView.cs
@@ -0,0 +1,112 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MinecraftClient.Inventory;
+
+namespace MinecraftClient.Tui
+{
+ public class CraftingView : ContainerViewBase
+ {
+ private readonly CraftingViewModel _craftVm;
+
+ public CraftingView(McClient handler, int windowId)
+ : base(new CraftingViewModel(handler, windowId))
+ {
+ _craftVm = (CraftingViewModel)_vm;
+ Initialize();
+ }
+
+ protected override int GetTotalSlotRows()
+ {
+ return 3 + 3 + 1;
+ }
+
+ protected override Control BuildContainerSpecificArea()
+ {
+ var row = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
+ var gridPanel = new StackPanel { Spacing = 0 };
+ gridPanel.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_crafting_grid,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ gridPanel.Children.Add(BuildSlotGrid(_craftVm.CraftingGridSlots, 3));
+ row.Children.Add(gridPanel);
+
+ row.Children.Add(new TextBlock
+ {
+ Text = " \u2192 ",
+ Foreground = Brushes.White,
+ FontWeight = FontWeight.Bold,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ var outPanel = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ outPanel.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_inventory_output,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ outPanel.Children.Add(CreateSlotCell(_craftVm.OutputSlot, 0, 0));
+ row.Children.Add(outPanel);
+
+ return row;
+ }
+ }
+
+ public class CraftingViewModel : ContainerViewModel
+ {
+ public ObservableCollection CraftingGridSlots { get; } = new();
+ public SlotViewModel OutputSlot { get; private set; } = null!;
+
+ public CraftingViewModel(McClient handler, int windowId)
+ : base(handler, windowId, ContainerType.Crafting)
+ {
+ OutputSlot = SlotMap[0];
+ }
+
+ protected override void InitializeSlots()
+ {
+ SlotMap.Clear();
+
+ var output = new SlotViewModel(0);
+ SlotMap[0] = output;
+
+ for (int i = 1; i <= 9; i++)
+ {
+ var slot = new SlotViewModel(i);
+ CraftingGridSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ for (int i = 10; i <= 36; i++)
+ {
+ var slot = new SlotViewModel(i);
+ MainInventorySlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ for (int i = 37; i <= 45; i++)
+ {
+ int hotbarIdx = i - 37;
+ var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
+ HotbarSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/EnchantingTableView.cs b/MinecraftClient/Tui/EnchantingTableView.cs
new file mode 100644
index 00000000..c1386d7a
--- /dev/null
+++ b/MinecraftClient/Tui/EnchantingTableView.cs
@@ -0,0 +1,198 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MinecraftClient.Inventory;
+
+namespace MinecraftClient.Tui
+{
+ public class EnchantingTableView : ContainerViewBase
+ {
+ private readonly EnchantingViewModel _enchantVm;
+ private readonly TextBlock[] _enchantNameLabels = new TextBlock[3];
+ private readonly TextBlock[] _enchantCostLabels = new TextBlock[3];
+
+ public EnchantingTableView(McClient handler, int windowId)
+ : base(new EnchantingViewModel(handler, windowId))
+ {
+ _enchantVm = (EnchantingViewModel)_vm;
+ Initialize();
+ }
+
+ private void RefreshEnchantOptions()
+ {
+ var container = _vm.Handler.GetInventory(_vm.WindowId);
+ if (container == null) return;
+
+ int protocolVersion = _vm.Handler.GetProtocolVersion();
+
+ for (int i = 0; i < 3; i++)
+ {
+ if (_enchantNameLabels[i] == null) continue;
+
+ short levelReq = container.Properties.TryGetValue(i, out var lr) ? lr : (short)0;
+ short enchantId = container.Properties.TryGetValue(i + 4, out var eid) ? eid : (short)-1;
+ short enchantLevel = container.Properties.TryGetValue(i + 7, out var el) ? el : (short)0;
+
+ if (levelReq > 0 && enchantId >= 0)
+ {
+ try
+ {
+ var enchant = EnchantmentMapping.GetEnchantmentById(protocolVersion, enchantId);
+ string name = EnchantmentMapping.GetEnchantmentName(enchant);
+ string roman = EnchantmentMapping.ConvertLevelToRomanNumbers(enchantLevel);
+ _enchantNameLabels[i].Text = $"{name} {roman}";
+ _enchantCostLabels[i].Text = $" ({levelReq})";
+ }
+ catch
+ {
+ _enchantNameLabels[i].Text = string.Format(Translations.tui_enchanting_option_slot, i + 1);
+ _enchantCostLabels[i].Text = levelReq > 0 ? $" ({levelReq})" : "";
+ }
+ }
+ else
+ {
+ _enchantNameLabels[i].Text = string.Format(Translations.tui_enchanting_option_slot, i + 1);
+ _enchantCostLabels[i].Text = "";
+ }
+ }
+ }
+
+ protected override void OnContainerDataChanged()
+ {
+ RefreshEnchantOptions();
+ }
+
+ protected override int GetTotalSlotRows()
+ {
+ return 3 + 3 + 1;
+ }
+
+ protected override Control BuildContainerSpecificArea()
+ {
+ var panel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Spacing = 0,
+ };
+
+ var slotsCol = new StackPanel
+ {
+ Spacing = 0,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ slotsCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_enchanting_item,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ slotsCol.Children.Add(CreateSlotCell(_enchantVm.ItemSlot, 0, 0));
+
+ slotsCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_enchanting_lapis,
+ Foreground = new SolidColorBrush(Color.FromRgb(60, 80, 200)),
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ slotsCol.Children.Add(CreateSlotCell(_enchantVm.LapisSlot, 1, 0));
+
+ panel.Children.Add(slotsCol);
+
+ var optionsCol = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(2, 0, 0, 0),
+ };
+
+ optionsCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_enchanting_options,
+ Foreground = Brushes.Magenta,
+ FontWeight = FontWeight.Bold,
+ });
+
+ int optionWidth = System.Math.Max(_slotW * 4, 30);
+
+ for (int i = 0; i < 3; i++)
+ {
+ var nameLabel = new TextBlock
+ {
+ Text = string.Format(Translations.tui_enchanting_option_slot, i + 1),
+ Foreground = new SolidColorBrush(Color.FromRgb(100, 200, 70)),
+ TextWrapping = TextWrapping.NoWrap,
+ };
+ _enchantNameLabels[i] = nameLabel;
+
+ var costLabel = new TextBlock
+ {
+ Foreground = new SolidColorBrush(Color.FromRgb(100, 200, 70)),
+ FontWeight = FontWeight.Bold,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ _enchantCostLabels[i] = costLabel;
+
+ var content = new DockPanel();
+ DockPanel.SetDock(costLabel, Dock.Right);
+ content.Children.Add(costLabel);
+ content.Children.Add(nameLabel);
+
+ optionsCol.Children.Add(new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(55, 50, 40)),
+ MinWidth = optionWidth,
+ MinHeight = _slotH,
+ Padding = new Thickness(1, 0),
+ Child = content,
+ });
+ }
+
+ RefreshEnchantOptions();
+
+ panel.Children.Add(optionsCol);
+
+ return panel;
+ }
+ }
+
+ public class EnchantingViewModel : ContainerViewModel
+ {
+ public SlotViewModel ItemSlot { get; private set; } = null!;
+ public SlotViewModel LapisSlot { get; private set; } = null!;
+
+ public EnchantingViewModel(McClient handler, int windowId)
+ : base(handler, windowId, ContainerType.Enchantment)
+ {
+ ItemSlot = SlotMap[0];
+ LapisSlot = SlotMap[1];
+ }
+
+ protected override void InitializeSlots()
+ {
+ SlotMap.Clear();
+
+ SlotMap[0] = new SlotViewModel(0);
+ SlotMap[1] = new SlotViewModel(1);
+
+ for (int i = 2; i <= 28; i++)
+ {
+ var slot = new SlotViewModel(i);
+ MainInventorySlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ for (int i = 29; i <= 37; i++)
+ {
+ int hotbarIdx = i - 29;
+ var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
+ HotbarSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/FurnaceView.cs b/MinecraftClient/Tui/FurnaceView.cs
new file mode 100644
index 00000000..cde8054c
--- /dev/null
+++ b/MinecraftClient/Tui/FurnaceView.cs
@@ -0,0 +1,133 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MinecraftClient.Inventory;
+
+namespace MinecraftClient.Tui
+{
+ public class FurnaceView : ContainerViewBase
+ {
+ private readonly FurnaceViewModel _furnaceVm;
+
+ public FurnaceView(McClient handler, int windowId, ContainerType type)
+ : base(new FurnaceViewModel(handler, windowId, type))
+ {
+ _furnaceVm = (FurnaceViewModel)_vm;
+ Initialize();
+ }
+
+ protected override int GetTotalSlotRows()
+ {
+ return 3 + 3 + 1;
+ }
+
+ protected override Control BuildContainerSpecificArea()
+ {
+ var panel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Spacing = 0,
+ };
+
+ var leftCol = new StackPanel
+ {
+ Spacing = 0,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ leftCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_furnace_input,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ leftCol.Children.Add(CreateSlotCell(_furnaceVm.InputSlot, 0, 0));
+
+ leftCol.Children.Add(new TextBlock
+ {
+ Text = "\u2592\u2592\u2592",
+ Foreground = new SolidColorBrush(Color.FromRgb(180, 100, 40)),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+
+ leftCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_furnace_fuel,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ leftCol.Children.Add(CreateSlotCell(_furnaceVm.FuelSlot, 1, 0));
+
+ panel.Children.Add(leftCol);
+
+ panel.Children.Add(new TextBlock
+ {
+ Text = " \u2192 ",
+ Foreground = Brushes.White,
+ FontWeight = FontWeight.Bold,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ var rightCol = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ rightCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_furnace_output,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ rightCol.Children.Add(CreateSlotCell(_furnaceVm.OutputSlot, 0, 1));
+
+ panel.Children.Add(rightCol);
+
+ return panel;
+ }
+ }
+
+ public class FurnaceViewModel : ContainerViewModel
+ {
+ public SlotViewModel InputSlot { get; private set; } = null!;
+ public SlotViewModel FuelSlot { get; private set; } = null!;
+ public SlotViewModel OutputSlot { get; private set; } = null!;
+
+ public FurnaceViewModel(McClient handler, int windowId, ContainerType type)
+ : base(handler, windowId, type)
+ {
+ InputSlot = SlotMap[0];
+ FuelSlot = SlotMap[1];
+ OutputSlot = SlotMap[2];
+ }
+
+ protected override void InitializeSlots()
+ {
+ SlotMap.Clear();
+
+ SlotMap[0] = new SlotViewModel(0);
+ SlotMap[1] = new SlotViewModel(1);
+ SlotMap[2] = new SlotViewModel(2);
+
+ for (int i = 3; i <= 29; i++)
+ {
+ var slot = new SlotViewModel(i);
+ MainInventorySlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ for (int i = 30; i <= 38; i++)
+ {
+ int hotbarIdx = i - 30;
+ var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
+ HotbarSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/GridContainerView.cs b/MinecraftClient/Tui/GridContainerView.cs
new file mode 100644
index 00000000..c2da293b
--- /dev/null
+++ b/MinecraftClient/Tui/GridContainerView.cs
@@ -0,0 +1,31 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MinecraftClient.Inventory;
+
+namespace MinecraftClient.Tui
+{
+ public class GridContainerView : ContainerViewBase
+ {
+ private readonly int _gridRows;
+ private readonly int _gridCols;
+
+ public GridContainerView(McClient handler, int windowId, ContainerType type, int rows, int cols)
+ : base(new ContainerViewModel(handler, windowId, type))
+ {
+ _gridRows = rows;
+ _gridCols = cols;
+ Initialize();
+ }
+
+ protected override int GetTotalSlotRows()
+ {
+ return _gridRows + 3 + 1;
+ }
+
+ protected override Control BuildContainerSpecificArea()
+ {
+ return BuildSlotGrid(_vm.ContainerSlots, _gridCols);
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/GrindstoneView.cs b/MinecraftClient/Tui/GrindstoneView.cs
new file mode 100644
index 00000000..04a283a6
--- /dev/null
+++ b/MinecraftClient/Tui/GrindstoneView.cs
@@ -0,0 +1,126 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MinecraftClient.Inventory;
+
+namespace MinecraftClient.Tui
+{
+ public class GrindstoneView : ContainerViewBase
+ {
+ private readonly GrindstoneViewModel _grindVm;
+
+ public GrindstoneView(McClient handler, int windowId)
+ : base(new GrindstoneViewModel(handler, windowId))
+ {
+ _grindVm = (GrindstoneViewModel)_vm;
+ Initialize();
+ }
+
+ protected override int GetTotalSlotRows()
+ {
+ return 2 + 3 + 1;
+ }
+
+ protected override Control BuildContainerSpecificArea()
+ {
+ var row = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Spacing = 0,
+ };
+
+ var inputCol = new StackPanel
+ {
+ Spacing = 0,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ inputCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_grindstone_input1,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ inputCol.Children.Add(CreateSlotCell(_grindVm.Input1Slot, 0, 0));
+
+ inputCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_grindstone_input2,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ inputCol.Children.Add(CreateSlotCell(_grindVm.Input2Slot, 1, 0));
+
+ row.Children.Add(inputCol);
+
+ row.Children.Add(new TextBlock
+ {
+ Text = "=>",
+ Foreground = Brushes.White,
+ FontWeight = FontWeight.Bold,
+ VerticalAlignment = VerticalAlignment.Center,
+ Padding = new Thickness(1, 0),
+ });
+
+ var outCol = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ outCol.Children.Add(new TextBlock
+ {
+ Text = Translations.tui_inventory_output,
+ Foreground = BrEquipLbl,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ outCol.Children.Add(CreateSlotCell(_grindVm.OutputSlot, 0, 1));
+ row.Children.Add(outCol);
+
+ return row;
+ }
+ }
+
+ public class GrindstoneViewModel : ContainerViewModel
+ {
+ public SlotViewModel Input1Slot { get; private set; } = null!;
+ public SlotViewModel Input2Slot { get; private set; } = null!;
+ public SlotViewModel OutputSlot { get; private set; } = null!;
+
+ public GrindstoneViewModel(McClient handler, int windowId)
+ : base(handler, windowId, ContainerType.Grindstone)
+ {
+ Input1Slot = SlotMap[0];
+ Input2Slot = SlotMap[1];
+ OutputSlot = SlotMap[2];
+ }
+
+ protected override void InitializeSlots()
+ {
+ SlotMap.Clear();
+
+ SlotMap[0] = new SlotViewModel(0);
+ SlotMap[1] = new SlotViewModel(1);
+ SlotMap[2] = new SlotViewModel(2);
+
+ for (int i = 3; i <= 29; i++)
+ {
+ var slot = new SlotViewModel(i);
+ MainInventorySlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+
+ for (int i = 30; i <= 38; i++)
+ {
+ int hotbarIdx = i - 30;
+ var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
+ HotbarSlots.Add(slot);
+ SlotMap[i] = slot;
+ }
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/HopperView.cs b/MinecraftClient/Tui/HopperView.cs
new file mode 100644
index 00000000..bb69b2ae
--- /dev/null
+++ b/MinecraftClient/Tui/HopperView.cs
@@ -0,0 +1,30 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using MinecraftClient.Inventory;
+
+namespace MinecraftClient.Tui
+{
+ public class HopperView : ContainerViewBase
+ {
+ public HopperView(McClient handler, int windowId)
+ : base(new ContainerViewModel(handler, windowId, ContainerType.Hopper))
+ {
+ Initialize();
+ }
+
+ protected override int GetTotalSlotRows()
+ {
+ return 1 + 3 + 1;
+ }
+
+ protected override Control BuildContainerSpecificArea()
+ {
+ var grid = BuildSlotGrid(_vm.ContainerSlots, 5);
+ return new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Children = { grid },
+ };
+ }
+ }
+}
diff --git a/MinecraftClient/Tui/InventoryApp.cs b/MinecraftClient/Tui/InventoryApp.cs
index ac71bb39..ea866300 100644
--- a/MinecraftClient/Tui/InventoryApp.cs
+++ b/MinecraftClient/Tui/InventoryApp.cs
@@ -2,6 +2,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Consolonia.Themes;
+using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
@@ -16,9 +17,15 @@ namespace MinecraftClient.Tui
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
+ var handler = InventoryTuiHost.ActiveHandler!;
+ var windowId = InventoryTuiHost.ActiveWindowId;
+ var container = handler.GetInventory(windowId);
+ var containerType = container?.Type ?? ContainerType.PlayerInventory;
+ var view = ContainerViewBase.CreateView(containerType, handler, windowId);
+
desktop.MainWindow = new Window
{
- Content = new InventoryMainView(),
+ Content = view,
Title = "MCC Inventory"
};
}
diff --git a/MinecraftClient/Tui/InventoryMainView.cs b/MinecraftClient/Tui/InventoryMainView.cs
index 7cd95974..d141870d 100644
--- a/MinecraftClient/Tui/InventoryMainView.cs
+++ b/MinecraftClient/Tui/InventoryMainView.cs
@@ -1,304 +1,39 @@
-using System;
-using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
-using Avalonia.Controls.ApplicationLifetimes;
-using Avalonia.Controls.Primitives;
-using Avalonia.Controls.Templates;
-using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
-using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
- public class InventoryMainView : UserControl
+ public class PlayerInventoryView : ContainerViewBase
{
- private static readonly IBrush BrSlotEmptyA = new SolidColorBrush(Color.FromRgb(40, 40, 40));
- private static readonly IBrush BrSlotEmptyB = new SolidColorBrush(Color.FromRgb(55, 55, 55));
- private static readonly IBrush BrSlotFillA = new SolidColorBrush(Color.FromRgb(60, 60, 75));
- private static readonly IBrush BrSlotFillB = new SolidColorBrush(Color.FromRgb(75, 75, 90));
- private static readonly IBrush BrSlotHover = new SolidColorBrush(Color.FromRgb(100, 100, 140));
- private static readonly IBrush BrName = Brushes.White;
- private static readonly IBrush BrCount = Brushes.Yellow;
- private static readonly IBrush BrDim = new SolidColorBrush(Color.FromRgb(80, 80, 80));
- private static readonly IBrush BrEquipLbl = Brushes.DarkCyan;
- private static readonly IBrush BrInfoHighlight = new SolidColorBrush(Color.FromRgb(40, 40, 60));
- private static readonly IBrush BrHeldItemBg = new SolidColorBrush(Color.FromRgb(60, 50, 80));
- private static readonly IBrush BrHeldItemBorder = Brushes.Yellow;
-
- private int _slotW;
- private int _slotH;
- private int _nameMaxLen;
- private int _nameLines;
+ private readonly PlayerInventoryViewModel _playerVm;
private int _topGap;
- private int _termW;
- private readonly InventoryViewModel _vm;
- private TextBlock _titleText = null!;
- private Border _infoDetailBorder = null!;
- private TextBlock _infoDetailText = null!;
- private TextBlock _cursorItemText = null!;
- private TextBlock _helpText = null!;
-
- private TextBlock[] _hotbarIndicators = new TextBlock[9];
- private int _currentHotbarSlot = -1;
-
- private Border? _lastHoveredSlotBorder;
-
- private Canvas _overlayCanvas = null!;
- private Border _heldItemFloater = null!;
- private TextBlock _heldItemFloaterName = null!;
- private TextBlock _heldItemFloaterCount = null!;
-
- private ScrollViewer _chatScrollViewer = null!;
- private ObservableCollection? _chatLines;
- private int _lastTermW;
- private int _lastTermH;
-
- public InventoryMainView()
+ public PlayerInventoryView(McClient handler, int windowId)
+ : base(new PlayerInventoryViewModel(handler, windowId))
{
- var handler = InventoryTuiHost.ActiveHandler
- ?? throw new InvalidOperationException("No active McClient");
- int windowId = InventoryTuiHost.ActiveWindowId;
-
- _vm = new InventoryViewModel(handler, windowId);
- _currentHotbarSlot = handler.GetCurrentSlot();
-
- _chatLines = TuiConsoleBackend.Instance?.GetView()?.GetRecentLogLines(50)
- ?? new ObservableCollection();
-
- RebuildUi();
+ _playerVm = (PlayerInventoryViewModel)_vm;
+ Initialize();
}
- private void RebuildUi()
+ protected override int GetTotalSlotRows()
{
- int termH;
- try
- {
- _termW = System.Console.WindowWidth;
- termH = System.Console.WindowHeight;
- }
- catch
- {
- _termW = 120;
- termH = 40;
- }
-
- _lastTermW = _termW;
- _lastTermH = termH;
-
- int availW = _termW - 26;
- _slotW = Math.Clamp(availW / 9, 8, 18);
- _nameMaxLen = _slotW;
-
- int topUsedW = _slotW * 4 + 8 + _slotW * 2 + 4 + _slotW;
- _topGap = Math.Max(2, (_slotW * 9 - topUsedW) / 2);
-
- _slotH = Math.Clamp((termH - 8) / 6, 2, 5);
- _nameLines = _slotH;
-
- _vm.SetSlotDisplayParams(_nameMaxLen, _nameLines);
-
- _lastHoveredSlotBorder = null;
-
- _titleText = new TextBlock
- {
- FontWeight = FontWeight.Bold,
- Foreground = Brushes.Cyan,
- HorizontalAlignment = HorizontalAlignment.Center,
- };
-
- _infoDetailText = new TextBlock
- {
- TextWrapping = TextWrapping.Wrap,
- Foreground = Brushes.White,
- };
-
- _infoDetailBorder = new Border
- {
- Background = Brushes.Transparent,
- Padding = new Thickness(0),
- Child = _infoDetailText,
- };
-
- _cursorItemText = new TextBlock
- {
- Foreground = Brushes.Yellow,
- FontWeight = FontWeight.Bold,
- TextWrapping = TextWrapping.Wrap,
- };
-
- _helpText = new TextBlock
- {
- TextWrapping = TextWrapping.Wrap,
- Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)),
- Text = Translations.tui_inventory_controls_help,
- };
-
- _heldItemFloaterName = new TextBlock
- {
- Foreground = Brushes.White,
- FontWeight = FontWeight.Bold,
- TextWrapping = TextWrapping.Wrap,
- };
- _heldItemFloaterCount = new TextBlock
- {
- Foreground = BrCount,
- FontWeight = FontWeight.Bold,
- };
- _heldItemFloater = new Border
- {
- Background = BrHeldItemBg,
- BorderBrush = BrHeldItemBorder,
- BorderThickness = new Thickness(1),
- Padding = new Thickness(1, 0),
- IsVisible = false,
- MaxWidth = 24,
- Child = new StackPanel
- {
- Children = { _heldItemFloaterName, _heldItemFloaterCount },
- },
- };
-
- _overlayCanvas = new Canvas { IsHitTestVisible = false };
- _overlayCanvas.Children.Add(_heldItemFloater);
-
- var chatLines = _chatLines!;
- chatLines.CollectionChanged += (_, _) =>
- {
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- var sv = _chatScrollViewer;
- if (sv.Extent.Height > sv.Viewport.Height)
- sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height);
- }, Avalonia.Threading.DispatcherPriority.Background);
- };
- var chatItemsControl = new ItemsControl
- {
- ItemsSource = chatLines,
- Focusable = false,
- ItemTemplate = new FuncDataTemplate((s, _) =>
- new TextBlock
- {
- Text = s,
- Foreground = Brushes.Gray,
- Padding = new Thickness(0),
- Margin = new Thickness(0),
- TextWrapping = TextWrapping.Wrap,
- }),
- };
- _chatScrollViewer = new ScrollViewer
- {
- Content = chatItemsControl,
- Background = Brushes.Black,
- HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
- VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
- Padding = new Thickness(0),
- };
-
- _hotbarIndicators = new TextBlock[9];
-
- Content = BuildRootLayout();
- UpdateTitle();
- UpdateInfoPanel();
-
- _chatScrollToBottom = true;
- _chatScrollViewer.ScrollChanged += OnChatScrollChanged;
+ return 6;
}
- private bool _chatScrollToBottom = true;
-
- private void OnChatScrollChanged(object? sender, ScrollChangedEventArgs e)
+ protected override void RebuildUi()
{
- if (!_chatScrollToBottom) return;
- var sv = _chatScrollViewer;
- if (sv.Extent.Height > sv.Viewport.Height)
- {
- sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height);
- _chatScrollToBottom = false;
- }
+ int availW = 0;
+ try { availW = System.Console.WindowWidth - 26; } catch { availW = 94; }
+ int slotW = System.Math.Clamp(availW / 9, 8, 18);
+ int topUsedW = slotW * 4 + 8 + slotW * 2 + 4 + slotW;
+ _topGap = System.Math.Max(2, (slotW * 9 - topUsedW) / 2);
+
+ base.RebuildUi();
}
- private Control BuildRootLayout()
- {
- // Layout (top-down):
- // Title
- // [InfoPanel(right)] [InventoryGrid(left)] <-- inventory area
- // ChatScrollViewer (full width, fills remaining)
-
- var inventoryArea = BuildMainArea();
- DockPanel.SetDock(_titleText, Dock.Top);
- DockPanel.SetDock(inventoryArea, Dock.Top);
-
- var mainContent = new DockPanel
- {
- Children = { _titleText, inventoryArea, _chatScrollViewer }
- };
-
- return new Panel
- {
- Background = Brushes.Black,
- Children = { mainContent, _overlayCanvas }
- };
- }
-
- private Control BuildMainArea()
- {
- var infoPanel = BuildInfoPanel();
- DockPanel.SetDock(infoPanel, Dock.Right);
-
- return new DockPanel
- {
- Children = { infoPanel, BuildInventoryPanel() }
- };
- }
-
- private Control BuildInfoPanel()
- {
- return new Border
- {
- BorderThickness = new Thickness(1),
- BorderBrush = Brushes.Gray,
- Padding = new Thickness(1),
- Width = 24,
- Child = new StackPanel
- {
- Children =
- {
- new TextBlock { Text = Translations.tui_inventory_item_info, FontWeight = FontWeight.Bold, Foreground = Brushes.Cyan },
- _infoDetailBorder,
- new TextBlock { Text = Translations.tui_inventory_held_item, FontWeight = FontWeight.Bold, Foreground = Brushes.Yellow, Margin = new Thickness(0, 1, 0, 0) },
- _cursorItemText,
- new TextBlock { Text = Translations.tui_inventory_controls, FontWeight = FontWeight.Bold, Foreground = Brushes.Green, Margin = new Thickness(0, 1, 0, 0) },
- _helpText,
- }
- }
- };
- }
-
- private Control BuildInventoryPanel()
- {
- var root = new StackPanel
- {
- Spacing = 0,
- HorizontalAlignment = HorizontalAlignment.Center,
- };
-
- root.Children.Add(BuildTopSection());
- root.Children.Add(new Border { Height = 1 });
- root.Children.Add(BuildSlotGrid(_vm.MainInventorySlots, 9));
- root.Children.Add(BuildHotbarSection());
-
- return new Border
- {
- BorderThickness = new Thickness(1),
- BorderBrush = Brushes.Gray,
- Child = root,
- };
- }
-
- private Control BuildTopSection()
+ protected override Control BuildContainerSpecificArea()
{
var row = new StackPanel
{
@@ -318,7 +53,7 @@ namespace MinecraftClient.Tui
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
- offPanel.Children.Add(CreateSlotCell(_vm.OffhandSlot, 0, 0));
+ offPanel.Children.Add(CreateSlotCell(_playerVm.OffhandSlot, 0, 0));
row.Children.Add(offPanel);
var equipGrid = new Grid
@@ -332,7 +67,7 @@ namespace MinecraftClient.Tui
var lbl = MakeLabel(label);
Grid.SetRow(lbl, r); Grid.SetColumn(lbl, gc);
equipGrid.Children.Add(lbl);
- var btn = CreateSlotCell(_vm.EquipmentSlots[eqIdx], r, gc / 2);
+ var btn = CreateSlotCell(_playerVm.EquipmentSlots[eqIdx], r, gc / 2);
Grid.SetRow(btn, r); Grid.SetColumn(btn, gc + 1);
equipGrid.Children.Add(btn);
}
@@ -354,7 +89,7 @@ namespace MinecraftClient.Tui
for (int ci = 0; ci < 4; ci++)
{
int cr = ci / 2, cc = ci % 2;
- var cs = CreateSlotCell(_vm.CraftingInputSlots[ci], cr, cc);
+ var cs = CreateSlotCell(_playerVm.CraftingInputSlots[ci], cr, cc);
Grid.SetRow(cs, cr);
Grid.SetColumn(cs, cc);
craftGrid.Children.Add(cs);
@@ -382,7 +117,7 @@ namespace MinecraftClient.Tui
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
- craftOutPanel.Children.Add(CreateSlotCell(_vm.CraftingOutputSlot, 0, 1));
+ craftOutPanel.Children.Add(CreateSlotCell(_playerVm.CraftingOutputSlot, 0, 1));
Grid.SetRow(craftOutPanel, 0); Grid.SetColumn(craftOutPanel, 3);
Grid.SetRowSpan(craftOutPanel, 2);
craftGrid.Children.Add(craftOutPanel);
@@ -390,363 +125,5 @@ namespace MinecraftClient.Tui
row.Children.Add(craftGrid);
return row;
}
-
- private Control BuildHotbarSection()
- {
- var panel = new StackPanel { Spacing = 0 };
-
- var numberRow = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- HorizontalAlignment = HorizontalAlignment.Center,
- };
- for (int i = 0; i < 9; i++)
- {
- bool active = i == _currentHotbarSlot;
- string label = active ? $"{i + 1} \u25bc" : $" {i + 1} ";
-
- var tb = new TextBlock
- {
- Text = label,
- Width = _slotW,
- TextAlignment = TextAlignment.Center,
- Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan,
- FontWeight = FontWeight.Bold,
- };
- _hotbarIndicators[i] = tb;
- numberRow.Children.Add(tb);
- }
- panel.Children.Add(numberRow);
- panel.Children.Add(BuildSlotGrid(_vm.HotbarSlots, 9));
- return panel;
- }
-
- private TextBlock MakeLabel(string text)
- {
- return new TextBlock
- {
- Text = text,
- Foreground = BrEquipLbl,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(1, 0, 0, 0),
- FontWeight = FontWeight.Bold,
- };
- }
-
- private Control BuildSlotGrid(ObservableCollection slots, int columns)
- {
- var grid = new Grid();
- int rows = (slots.Count + columns - 1) / columns;
-
- for (int r = 0; r < rows; r++)
- grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
- for (int c = 0; c < columns; c++)
- grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
-
- for (int i = 0; i < slots.Count; i++)
- {
- int row = i / columns;
- int col = i % columns;
- var cell = CreateSlotCell(slots[i], row, col);
- Grid.SetRow(cell, row);
- Grid.SetColumn(cell, col);
- grid.Children.Add(cell);
- }
-
- return grid;
- }
-
- private static IBrush GetSlotBg(bool isEmpty, int row, int col)
- {
- bool isA = (row + col) % 2 == 0;
- return isEmpty
- ? (isA ? BrSlotEmptyA : BrSlotEmptyB)
- : (isA ? BrSlotFillA : BrSlotFillB);
- }
-
- private Border CreateSlotCell(SlotViewModel slot, int row = 0, int col = 0)
- {
- var nameTb = new TextBlock
- {
- TextWrapping = TextWrapping.Wrap,
- Padding = new Thickness(0),
- Margin = new Thickness(0),
- VerticalAlignment = VerticalAlignment.Top,
- };
-
- var countTb = new TextBlock
- {
- Foreground = BrCount,
- FontWeight = FontWeight.Bold,
- Padding = new Thickness(0),
- Margin = new Thickness(0),
- HorizontalAlignment = HorizontalAlignment.Right,
- VerticalAlignment = VerticalAlignment.Bottom,
- };
-
- ApplySlotVisual(slot, nameTb, countTb);
-
- int r = row, c = col;
- var border = new Border
- {
- Width = _slotW,
- Height = _slotH,
- Background = GetSlotBg(slot.IsEmpty, r, c),
- Child = new Panel
- {
- Children = { nameTb, countTb },
- },
- Tag = (slot, r, c),
- };
-
- border.PointerPressed += OnSlotPointerPressed;
- border.PointerEntered += OnSlotPointerEnter;
- border.PointerExited += OnSlotPointerExit;
- border.PointerMoved += OnSlotPointerMoved;
-
- slot.PropertyChanged += (_, _) =>
- {
- ApplySlotVisual(slot, nameTb, countTb);
- border.Background = GetSlotBg(slot.IsEmpty, r, c);
- };
-
- return border;
- }
-
- private void ApplySlotVisual(SlotViewModel slot, TextBlock nameTb, TextBlock countTb)
- {
- if (slot.IsEmpty)
- {
- nameTb.Text = "";
- nameTb.Foreground = BrDim;
- countTb.Text = "";
- }
- else
- {
- nameTb.Text = slot.ItemDisplayText;
- nameTb.Foreground = BrName;
- countTb.Text = slot.CountDisplay;
- }
- }
-
- private void OnSlotPointerPressed(object? sender, PointerPressedEventArgs e)
- {
- if (sender is not Border border || border.Tag is not (SlotViewModel slot, int, int))
- return;
-
- SetHover(border, slot);
-
- var point = e.GetCurrentPoint(border);
- bool isShift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
-
- WindowActionType action;
- if (point.Properties.IsRightButtonPressed)
- action = isShift ? WindowActionType.ShiftRightClick : WindowActionType.RightClick;
- else
- action = isShift ? WindowActionType.ShiftClick : WindowActionType.LeftClick;
-
- _vm.PerformAction(slot.SlotId, action);
- UpdateInfoPanel();
- UpdateHeldItemFloater(e);
- e.Handled = true;
- }
-
- private void OnSlotPointerEnter(object? sender, PointerEventArgs e)
- {
- if (sender is Border b && b.Tag is (SlotViewModel slot, int, int))
- {
- SetHover(b, slot);
- UpdateHeldItemFloater(e);
- }
- }
-
- private void OnSlotPointerMoved(object? sender, PointerEventArgs e)
- {
- if (sender is Border b && b.Tag is (SlotViewModel slot, int, int))
- {
- SetHover(b, slot);
- UpdateHeldItemFloater(e);
- }
- }
-
- private void OnSlotPointerExit(object? sender, PointerEventArgs e)
- {
- if (sender is Border b && b.Tag is (SlotViewModel slot, int row, int col))
- b.Background = GetSlotBg(slot.IsEmpty, row, col);
- }
-
- private void SetHover(Border border, SlotViewModel slot)
- {
- if (_lastHoveredSlotBorder != null && _lastHoveredSlotBorder != border)
- {
- if (_lastHoveredSlotBorder.Tag is (SlotViewModel oldSlot, int or, int oc))
- _lastHoveredSlotBorder.Background = GetSlotBg(oldSlot.IsEmpty, or, oc);
- }
-
- _lastHoveredSlotBorder = border;
- border.Background = BrSlotHover;
- _vm.HoveredSlot = slot;
- UpdateInfoPanel();
- }
-
- private void UpdateHeldItemFloater(PointerEventArgs e)
- {
- if (!_vm.HasCursorItem)
- {
- _heldItemFloater.IsVisible = false;
- return;
- }
-
- _heldItemFloaterName.Text = _vm.CursorItemInfo;
- _heldItemFloaterCount.Text = "";
-
- try
- {
- var pos = e.GetPosition(_overlayCanvas);
- double left = pos.X + 2;
- double remainingW = _termW - left - 2;
- int maxW = Math.Max(8, (int)remainingW);
- _heldItemFloater.MaxWidth = maxW;
- Canvas.SetLeft(_heldItemFloater, left);
- Canvas.SetTop(_heldItemFloater, pos.Y);
- }
- catch
- {
- _heldItemFloater.MaxWidth = 24;
- Canvas.SetLeft(_heldItemFloater, 0);
- Canvas.SetTop(_heldItemFloater, 0);
- }
-
- _heldItemFloater.IsVisible = true;
- }
-
- private void UpdateInfoPanel()
- {
- _infoDetailText.Text = _vm.HoveredSlotDetailText;
-
- bool hasHoveredItem = _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty;
- _infoDetailBorder.Background = hasHoveredItem ? BrInfoHighlight : Brushes.Transparent;
-
- if (_vm.HasCursorItem)
- {
- _cursorItemText.Text = _vm.CursorItemInfo;
- _cursorItemText.Foreground = Brushes.Yellow;
- }
- else
- {
- _cursorItemText.Text = Translations.tui_inventory_cursor_empty;
- _cursorItemText.Foreground = BrDim;
- _heldItemFloater.IsVisible = false;
- }
- }
-
- private void UpdateTitle()
- {
- _titleText.Text = _vm.Title;
- }
-
- private void CloseInventory()
- {
- if (ConsoleIO.Backend is TuiConsoleBackend tuiBackend)
- tuiBackend.GetView()?.HideOverlay();
- else
- (Application.Current?.ApplicationLifetime as IControlledApplicationLifetime)?.Shutdown();
- }
-
- protected override void OnKeyDown(KeyEventArgs e)
- {
- base.OnKeyDown(e);
-
- switch (e.Key)
- {
- case Key.Escape:
- case Key.E:
- CloseInventory();
- e.Handled = true;
- break;
-
- case Key.C:
- if ((e.KeyModifiers & KeyModifiers.Shift) != 0 &&
- _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty)
- {
- _vm.PerformAction(_vm.HoveredSlot.SlotId, WindowActionType.ShiftClick);
- UpdateInfoPanel();
- }
- e.Handled = true;
- break;
-
- case Key.Q:
- if (_vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty)
- {
- var action = (e.KeyModifiers & KeyModifiers.Control) != 0
- ? WindowActionType.DropItemStack
- : WindowActionType.DropItem;
- _vm.PerformAction(_vm.HoveredSlot.SlotId, action);
- UpdateInfoPanel();
- }
- e.Handled = true;
- break;
-
- case Key.R:
- _vm.RefreshFromContainer();
- _currentHotbarSlot = _vm.Handler.GetCurrentSlot();
- UpdateHotbarIndicators();
- UpdateInfoPanel();
- e.Handled = true;
- break;
- }
- }
-
- private void UpdateHotbarIndicators()
- {
- for (int i = 0; i < 9; i++)
- {
- bool active = i == _currentHotbarSlot;
- _hotbarIndicators[i].Text = active ? $"{i + 1} \u25bc" : $" {i + 1} ";
- _hotbarIndicators[i].Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan;
- }
- }
-
- protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
- {
- base.OnAttachedToVisualTree(e);
- Focusable = true;
- Focus();
- AddHandler(KeyDownEvent, OnTunnelKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel);
- SizeChanged += OnViewSizeChanged;
- }
-
- private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
- {
- if (e.Key == Key.Escape)
- {
- CloseInventory();
- e.Handled = true;
- }
- }
-
- private void OnViewSizeChanged(object? sender, SizeChangedEventArgs e)
- {
- int newW, newH;
- try
- {
- newW = System.Console.WindowWidth;
- newH = System.Console.WindowHeight;
- }
- catch { return; }
-
- if (newW == _lastTermW && newH == _lastTermH) return;
-
- _vm.RefreshFromContainer();
- _currentHotbarSlot = _vm.Handler.GetCurrentSlot();
- RebuildUi();
- Focus();
- }
-
- protected override void OnGotFocus(GotFocusEventArgs e)
- {
- base.OnGotFocus(e);
- Focusable = true;
- }
}
}
diff --git a/MinecraftClient/Tui/InventoryTuiHost.cs b/MinecraftClient/Tui/InventoryTuiHost.cs
index c67c1892..f740ffd4 100644
--- a/MinecraftClient/Tui/InventoryTuiHost.cs
+++ b/MinecraftClient/Tui/InventoryTuiHost.cs
@@ -22,6 +22,30 @@ namespace MinecraftClient.Tui
public static bool IsRunning => _isRunning;
+ ///
+ /// Called by McClient.OnInventoryClose when the server closes a container.
+ /// If the closed window matches the active TUI window, auto-close the TUI.
+ ///
+ public static void NotifyInventoryClosed(int windowId)
+ {
+ if (!_isRunning || windowId != ActiveWindowId)
+ return;
+
+ if (ConsoleIO.Backend is TuiConsoleBackend)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ var view = TuiConsoleBackend.Instance?.GetView();
+ view?.HideOverlay();
+ });
+ }
+ else
+ {
+ (Avalonia.Application.Current?.ApplicationLifetime
+ as Avalonia.Controls.ApplicationLifetimes.IControlledApplicationLifetime)?.Shutdown();
+ }
+ }
+
///
/// Whether the TUI can be launched (classic mode has a one-shot limit).
///
@@ -89,7 +113,10 @@ namespace MinecraftClient.Tui
var view = TuiConsoleBackend.Instance?.GetView();
if (view != null)
{
- var content = new InventoryMainView();
+ var container = ActiveHandler!.GetInventory(ActiveWindowId);
+ var content = ContainerViewBase.CreateView(
+ container?.Type ?? ContainerType.PlayerInventory,
+ ActiveHandler, ActiveWindowId);
view.ShowOverlay(content, () =>
{
ActiveHandler = null;
diff --git a/MinecraftClient/Tui/InventoryViewModel.cs b/MinecraftClient/Tui/InventoryViewModel.cs
index bfb383b2..3b29928f 100644
--- a/MinecraftClient/Tui/InventoryViewModel.cs
+++ b/MinecraftClient/Tui/InventoryViewModel.cs
@@ -1,152 +1,48 @@
-using System;
-using System.Collections.Generic;
using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Runtime.CompilerServices;
-using System.Text;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
- public class InventoryViewModel : INotifyPropertyChanged
+ public class PlayerInventoryViewModel : ContainerViewModel
{
- private SlotViewModel? _hoveredSlot;
- private string _title = "";
- private string _statusText = "";
- private string _cursorItemInfo = "";
- private bool _hasCursorItem;
-
- public McClient Handler { get; }
- public int WindowId { get; }
-
public ObservableCollection EquipmentSlots { get; } = new();
public ObservableCollection CraftingInputSlots { get; } = new();
public SlotViewModel CraftingOutputSlot { get; }
- public ObservableCollection MainInventorySlots { get; } = new();
- public ObservableCollection HotbarSlots { get; } = new();
public SlotViewModel OffhandSlot { get; }
- public string Title
+ public PlayerInventoryViewModel(McClient handler, int windowId)
+ : base(handler, windowId, ContainerType.PlayerInventory)
{
- get => _title;
- set { _title = value; OnPropertyChanged(); }
+ CraftingOutputSlot = SlotMap[0];
+ OffhandSlot = SlotMap[45];
}
- public string StatusText
+ protected override void InitializeSlots()
{
- get => _statusText;
- set { _statusText = value; OnPropertyChanged(); }
- }
+ SlotMap.Clear();
- public string CursorItemInfo
- {
- get => _cursorItemInfo;
- set { _cursorItemInfo = value; OnPropertyChanged(); }
- }
-
- public bool HasCursorItem
- {
- get => _hasCursorItem;
- set { _hasCursorItem = value; OnPropertyChanged(); }
- }
-
- public SlotViewModel? HoveredSlot
- {
- get => _hoveredSlot;
- set
- {
- if (_hoveredSlot != null)
- _hoveredSlot.IsHovered = false;
- _hoveredSlot = value;
- if (_hoveredSlot != null)
- _hoveredSlot.IsHovered = true;
- OnPropertyChanged();
- OnPropertyChanged(nameof(HoveredSlotDetailText));
- }
- }
-
- ///
- /// Multi-line detail text for the hovered slot.
- ///
- public string HoveredSlotDetailText
- {
- get
- {
- if (_hoveredSlot == null)
- return Translations.tui_inventory_hover_hint;
-
- if (_hoveredSlot.IsEmpty)
- return $"Slot #{_hoveredSlot.SlotId}\n{Translations.tui_inventory_slot_empty}";
-
- var sb = new StringBuilder();
- sb.AppendLine(_hoveredSlot.ItemTypeName);
- sb.AppendLine(string.Format(Translations.tui_inventory_slot_detail, _hoveredSlot.SlotId, _hoveredSlot.ItemCount));
-
- string fullInfo = _hoveredSlot.FullInfo;
- if (!string.IsNullOrEmpty(fullInfo))
- {
- string[] parts = fullInfo.Split(" | ");
- for (int i = 1; i < parts.Length; i++)
- sb.AppendLine(parts[i].Trim());
- }
-
- return sb.ToString().TrimEnd();
- }
- }
-
- private Dictionary _slotMap = new();
- private int _nameMaxLen = 9;
- private int _nameMaxLines = 1;
-
- public InventoryViewModel(McClient handler, int windowId)
- {
- Handler = handler;
- WindowId = windowId;
-
- CraftingOutputSlot = new SlotViewModel(0);
- OffhandSlot = new SlotViewModel(45);
-
- InitializeSlots();
- RefreshFromContainer();
- }
-
- public void SetSlotDisplayParams(int maxWidth, int maxLines)
- {
- _nameMaxLen = maxWidth;
- _nameMaxLines = maxLines;
- foreach (var kvp in _slotMap)
- {
- kvp.Value.NameMaxWidth = maxWidth;
- kvp.Value.NameMaxLines = maxLines;
- }
- RefreshFromContainer();
- }
-
- private void InitializeSlots()
- {
- _slotMap.Clear();
-
- _slotMap[0] = CraftingOutputSlot;
+ var craftOut = new SlotViewModel(0);
+ SlotMap[0] = craftOut;
for (int i = 1; i <= 4; i++)
{
var slot = new SlotViewModel(i);
CraftingInputSlots.Add(slot);
- _slotMap[i] = slot;
+ SlotMap[i] = slot;
}
for (int i = 5; i <= 8; i++)
{
var slot = new SlotViewModel(i);
EquipmentSlots.Add(slot);
- _slotMap[i] = slot;
+ SlotMap[i] = slot;
}
for (int i = 9; i <= 35; i++)
{
var slot = new SlotViewModel(i);
MainInventorySlots.Add(slot);
- _slotMap[i] = slot;
+ SlotMap[i] = slot;
}
for (int i = 36; i <= 44; i++)
@@ -154,67 +50,11 @@ namespace MinecraftClient.Tui
int hotbarIdx = i - 36;
var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
HotbarSlots.Add(slot);
- _slotMap[i] = slot;
+ SlotMap[i] = slot;
}
- _slotMap[45] = OffhandSlot;
- }
-
- public void RefreshFromContainer()
- {
- Inventory.Container? container = Handler.GetInventory(WindowId);
- if (container == null)
- {
- StatusText = Translations.tui_inventory_container_not_found;
- return;
- }
-
- Title = string.Format(Translations.tui_inventory_title, WindowId, container.Title);
-
- foreach (var kvp in _slotMap)
- {
- Item? item = container.Items.TryGetValue(kvp.Key, out var it) ? it : null;
- kvp.Value.Update(item);
- }
-
- UpdateCursorItem(container);
- int itemCount = 0;
- foreach (var kvp in container.Items)
- {
- if (kvp.Key >= 0 && !kvp.Value.IsEmpty)
- itemCount++;
- }
- StatusText = string.Format(Translations.tui_inventory_item_count, itemCount);
-
- OnPropertyChanged(nameof(HoveredSlotDetailText));
- }
-
- private void UpdateCursorItem(Inventory.Container container)
- {
- if (container.Items.TryGetValue(-1, out var cursorItem) && !cursorItem.IsEmpty)
- {
- CursorItemInfo = $"x{cursorItem.Count} {cursorItem.GetTypeString()}";
- HasCursorItem = true;
- }
- else
- {
- CursorItemInfo = "";
- HasCursorItem = false;
- }
- }
-
- public bool PerformAction(int slotId, WindowActionType action)
- {
- bool result = Handler.DoWindowAction(WindowId, slotId, action);
- RefreshFromContainer();
- return result;
- }
-
- public event PropertyChangedEventHandler? PropertyChanged;
-
- private void OnPropertyChanged([CallerMemberName] string? name = null)
- {
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ var offhand = new SlotViewModel(45);
+ SlotMap[45] = offhand;
}
}
}