TUI support for more container

This commit is contained in:
BruceChen 2026-03-28 02:17:09 +08:00
parent ef133f3d6d
commit 0194380fbc
17 changed files with 2045 additions and 822 deletions

View file

@ -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<SlotViewModel> 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;
}
}
}
}

View file

@ -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<string>? _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<string>();
}
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<string>((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<SlotViewModel> 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}"),
};
}
}
}

View file

@ -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<SlotViewModel> ContainerSlots { get; } = new();
public ObservableCollection<SlotViewModel> MainInventorySlots { get; } = new();
public ObservableCollection<SlotViewModel> 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<int, SlotViewModel> 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<MaxDamageComponent>().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<string, object> 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));
}
}
}

View file

@ -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<SlotViewModel> 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;
}
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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 },
};
}
}
}

View file

@ -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"
};
}

View file

@ -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<string>? _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<string>();
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<string>((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<SlotViewModel> 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;
}
}
}

View file

@ -22,6 +22,30 @@ namespace MinecraftClient.Tui
public static bool IsRunning => _isRunning;
/// <summary>
/// Called by McClient.OnInventoryClose when the server closes a container.
/// If the closed window matches the active TUI window, auto-close the TUI.
/// </summary>
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();
}
}
/// <summary>
/// Whether the TUI can be launched (classic mode has a one-shot limit).
/// </summary>
@ -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;

View file

@ -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<SlotViewModel> EquipmentSlots { get; } = new();
public ObservableCollection<SlotViewModel> CraftingInputSlots { get; } = new();
public SlotViewModel CraftingOutputSlot { get; }
public ObservableCollection<SlotViewModel> MainInventorySlots { get; } = new();
public ObservableCollection<SlotViewModel> 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));
}
}
/// <summary>
/// Multi-line detail text for the hovered slot.
/// </summary>
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<int, SlotViewModel> _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;
}
}
}