diff --git a/editor/App.axaml b/editor/App.axaml index 3aed34e6d..2e5323c86 100644 --- a/editor/App.axaml +++ b/editor/App.axaml @@ -22,6 +22,7 @@ + @@ -440,6 +441,8 @@ @@ -484,6 +487,7 @@ + \ No newline at end of file diff --git a/editor/Assets/AssetBrowserView.axaml b/editor/Assets/AssetBrowserView.axaml index 80de4e38b..42522589d 100644 --- a/editor/Assets/AssetBrowserView.axaml +++ b/editor/Assets/AssetBrowserView.axaml @@ -47,6 +47,15 @@ Value="{StaticResource Red}" /> + + + + - @@ -126,21 +135,21 @@ + Padding="6"> + VerticalAlignment="Center" Classes="compact-text" /> - - + - - + - - + @@ -189,113 +192,107 @@ - - - + Value="{Binding DefaultScript, Mode=TwoWay}" Classes="compact-field" /> - + Options="{x:Static ve:VoxelEditorViewModel.FillModes}" Classes="compact-field" /> + - - + + - + - + + + - + RenderMode.VoxelPaletteId, VoxelViewMode.PhysicsMaterial => RenderMode.VoxelMaterials, VoxelViewMode.DdaSteps => RenderMode.VoxelSteps, + VoxelViewMode.Volumes => RenderMode.VoxelVolumes, _ => RenderMode.Lit }; } diff --git a/editor/VoxelEditor/VoxelOrthoView.cs b/editor/VoxelEditor/VoxelOrthoView.cs index cf871ba63..9ac52b0fe 100644 --- a/editor/VoxelEditor/VoxelOrthoView.cs +++ b/editor/VoxelEditor/VoxelOrthoView.cs @@ -19,7 +19,7 @@ public sealed class VoxelOrthoView : Control { AvaloniaProperty.Register(nameof(Projection)); private const double HandleSize = 8; - private const double MinScale = 1; + private const double MinScale = 0.01; // only keeps the maths finite private const double MaxScale = 80; private static readonly Typeface s_labelFont = new("Consolas"); diff --git a/editor/VoxelEditor/VoxelPalettePicker.cs b/editor/VoxelEditor/VoxelPalettePicker.cs index 5d504f784..c4d080311 100644 --- a/editor/VoxelEditor/VoxelPalettePicker.cs +++ b/editor/VoxelEditor/VoxelPalettePicker.cs @@ -121,9 +121,10 @@ public VoxelPalettePicker() { Background = CheckerBrush.Instance, ClipToBounds = true, Child = m_swatch }; var button = new Button { - Classes = { "chev" }, + Classes = { "chev", "compact-control" }, + Height = 26, CornerRadius = new CornerRadius(6), - Content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, Children = { checker, m_label } }, + Content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, Children = { checker, m_label } }, Flyout = m_flyout }; ToolTip.SetTip(button, "Palette ID new volumes, Paint and Bucket use, it also recolours the selected volume"); diff --git a/editor/VoxelEditor/VoxelViewPanel.cs b/editor/VoxelEditor/VoxelViewPanel.cs new file mode 100644 index 000000000..1f17e7126 --- /dev/null +++ b/editor/VoxelEditor/VoxelViewPanel.cs @@ -0,0 +1,75 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; + +namespace editor.VoxelEditor; + +public sealed class VoxelViewPanel : Panel { + private const double Gap = 4; + private double m_sideSplit = 0.5; + private double m_topSplit = 0.5; + + public static readonly StyledProperty FourUpProperty = + AvaloniaProperty.Register(nameof(FourUp)); + + static VoxelViewPanel() { + AffectsMeasure(FourUpProperty); + } + + public VoxelViewPanel() { + ClipToBounds = true; + AddHandler(Thumb.DragDeltaEvent, OnDividerDrag); + } + + public bool FourUp { + get => GetValue(FourUpProperty); + set => SetValue(FourUpProperty, value); + } + + private void OnDividerDrag(object? sender, VectorEventArgs e) { + if (!FourUp || Bounds.Width <= 0 || Bounds.Height <= 0) return; + switch ((e.Source as Control)?.Name) { + case "ViewsColumnSplitter": + m_sideSplit = Math.Clamp(m_sideSplit + e.Vector.X / Bounds.Width, 0.15, 0.85); + break; + case "ViewsRowSplitter": + m_topSplit = Math.Clamp(m_topSplit + e.Vector.Y / Bounds.Height, 0.15, 0.85); + break; + default: return; + } + InvalidateMeasure(); + e.Handled = true; + } + + private Rect Frame(Control child, Size size) { + var halfWidth = size.Width / 2; + var halfHeight = size.Height / 2; + var side = size.Width * m_sideSplit; + var top = size.Height * m_topSplit; + var inset = Gap / 2; + return child.Name switch { + "FrontViewHost" => new Rect(0, 0, Math.Max(0, Math.Max(halfWidth, side) - inset), Math.Max(0, Math.Max(halfHeight, top) - inset)), + "SideViewHost" => new Rect(side + inset, 0, Math.Max(0, size.Width - side - inset), Math.Max(0, halfHeight - inset)), + "TopViewHost" => new Rect(0, top + inset, Math.Max(0, halfWidth - inset), Math.Max(0, size.Height - top - inset)), + "ViewportHost" when FourUp => new Rect(halfWidth + inset, halfHeight + inset, Math.Max(0, halfWidth - inset), Math.Max(0, halfHeight - inset)), + "ViewportHost" => new Rect(size), + "ViewsColumnSplitter" => new Rect(side - inset, 0, Gap, Math.Min(top, halfHeight)), + "ViewsRowSplitter" => new Rect(0, top - inset, Math.Min(side, halfWidth), Gap), + _ => default + }; + } + + protected override Size MeasureOverride(Size availableSize) { + var size = new Size(double.IsFinite(availableSize.Width) ? availableSize.Width : 0, + double.IsFinite(availableSize.Height) ? availableSize.Height : 0); + foreach (var child in Children) child.Measure(Frame(child, size).Size); + return size; + } + + protected override Size ArrangeOverride(Size finalSize) { + foreach (var child in Children) child.Arrange(Frame(child, finalSize)); + return finalSize; + } +} diff --git a/editor/Workspace/DockFactory.cs b/editor/Workspace/DockFactory.cs index 519b33426..342b60ed2 100644 --- a/editor/Workspace/DockFactory.cs +++ b/editor/Workspace/DockFactory.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; +using Avalonia.Threading; using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.Mvvm; @@ -13,7 +15,7 @@ namespace editor.Workspace; public class DockFactory : Factory { - private const double SidePanelProportion = 0.2; + private const double SidePanelProportion = 0.16; private IDocumentDock? m_documentDock; private bool m_genericClosePending; @@ -71,7 +73,7 @@ public override IRootDock CreateLayout() { m_leftToolDock = leftToolDock; var leftDock = new ProportionalDock { - Proportion = 0.2, + Proportion = SidePanelProportion, AllowedDropOperations = DockOperationMask.None, Orientation = Orientation.Vertical, VisibleDockables = CreateList(leftToolDock) @@ -87,7 +89,7 @@ public override IRootDock CreateLayout() { m_rightToolDock = rightToolDock; var rightDock = new ProportionalDock { - Proportion = 0.22, + Proportion = SidePanelProportion, Orientation = Orientation.Vertical, VisibleDockables = CreateList(rightToolDock) }; @@ -118,9 +120,23 @@ public override IRootDock CreateLayout() { m_rootDock = root; m_documentDock = documentDock; + documentDock.PropertyChanged += OnDocumentDockChanged; return root; } + private void OnDocumentDockChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName != nameof(IDock.ActiveDockable) || !WorkspaceViewModel.AnyPlayActive) return; + if (m_documentDock?.ActiveDockable is not WorkspaceViewModel { IsPlayModeActive: false }) return; + + var playing = m_documentDock.VisibleDockables?.OfType().FirstOrDefault(w => w.IsPlayModeActive); + if (playing is null) return; + + Dispatcher.UIThread.Post(() => { + if (playing.IsPlayModeActive && m_documentDock?.VisibleDockables?.Contains(playing) == true) + SetActiveDockable(playing); + }); + } + public override void InitLayout(IDockable layout) { ContextLocator = new Dictionary> { ["Workspace"] = () => layout, diff --git a/editor/Workspace/HierarchyConnectorLayer.cs b/editor/Workspace/HierarchyConnectorLayer.cs index 083c2dc61..fe9bf5db2 100644 --- a/editor/Workspace/HierarchyConnectorLayer.cs +++ b/editor/Workspace/HierarchyConnectorLayer.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System; using Avalonia; using Avalonia.Controls; using Avalonia.Media; @@ -7,7 +8,7 @@ namespace editor.Workspace; // You dont even know how much this took -x public sealed class HierarchyConnectorLayer : Control { - public const double RowHeight = 28; + public const double RowHeight = 26; public const double IndentStep = 20; public const double LeftPad = 10; // left inset of the whole tree public const double SwatchHalf = 8; @@ -24,13 +25,28 @@ public sealed class HierarchyConnectorLayer : Control { private IReadOnlyList m_rows = []; private int m_selectedIndex = -1; + private int m_hoveredIndex = -1; + private double[] m_offsets = [0]; + public int HoveredIndex { + get => m_hoveredIndex; + set { if (m_hoveredIndex == value) return; m_hoveredIndex = value; InvalidateVisual(); } + } + public void RefreshGeometry() { + m_offsets = new double[m_rows.Count + 1]; + for (var i = 0; i < m_rows.Count; i++) m_offsets[i + 1] = m_offsets[i] + m_rows[i].VisualHeight; + InvalidateMeasure(); InvalidateVisual(); + } + public double GetRowTop(int index) => m_offsets[Math.Clamp(index, 0, m_offsets.Length - 1)]; + public int GetRowAt(double y) { + for (var i = 0; i < m_rows.Count; i++) if (y >= m_offsets[i] && y < m_offsets[i + 1]) return i; + return -1; + } public IReadOnlyList Rows { get => m_rows; set { m_rows = value; - InvalidateMeasure(); - InvalidateVisual(); + RefreshGeometry(); } } @@ -52,7 +68,7 @@ public void SetDropLine(int index, int depth) { } protected override Size MeasureOverride(Size availableSize) { - return new Size(0, m_rows.Count * RowHeight); + return new Size(0, m_offsets[^1]); // width 0 = stretches to the grid cell } @@ -64,13 +80,21 @@ private static double CenterX(int depth) { return ContentX(depth) + SwatchHalf; } - private static double CenterY(int index) { - return index * RowHeight + RowHeight / 2; + private double CenterY(int index) { + return GetRowTop(index) + m_rows[index].VisualHeight / 2; } public override void Render(DrawingContext ctx) { DrawSelection(ctx); DrawDropLine(ctx); + if (m_hoveredIndex >= 0 && m_hoveredIndex < m_rows.Count) { + var last = m_hoveredIndex; + while (last + 1 < m_rows.Count && m_rows[last + 1].Depth > m_rows[m_hoveredIndex].Depth) last++; + var x = ContentX(m_rows[m_hoveredIndex].Depth) - BoxLeftPad; + var rect = new Rect(x, GetRowTop(m_hoveredIndex), Math.Max(0, Bounds.Width - x - BoxRightPad), GetRowTop(last + 1) - GetRowTop(m_hoveredIndex)); + if (rect.Width > 2 && rect.Height > 2) + ctx.DrawRectangle(null, new Pen(Resolve("Bg4", "#383637"), 2), new RoundedRect(rect.Deflate(1), 8)); + } } private void DrawSelection(DrawingContext ctx) { @@ -88,9 +112,9 @@ private void DrawSelection(DrawingContext ctx) { // selection box var box = new Rect( ContentX(selDepth) - BoxLeftPad, - m_selectedIndex * RowHeight, + GetRowTop(m_selectedIndex), Bounds.Width - (ContentX(selDepth) - BoxLeftPad) - BoxRightPad, - (lastDesc - m_selectedIndex + 1) * RowHeight); + GetRowTop(lastDesc + 1) - GetRowTop(m_selectedIndex)); ctx.DrawRectangle(bg3, null, new RoundedRect(box, BoxCorner)); // descendant connectors @@ -117,7 +141,7 @@ private void DrawDropLine(DrawingContext ctx) { if (m_dropLineIndex < 0) return; var red = Resolve("Red", "#ff1659"); - var y = m_dropLineIndex * RowHeight; + var y = GetRowTop(m_dropLineIndex); var x0 = ContentX(m_dropLineDepth); var pen = new Pen(red, DropLineWidth) { LineCap = PenLineCap.Round }; diff --git a/editor/Workspace/HierarchyView.axaml b/editor/Workspace/HierarchyView.axaml index cde85eb6a..e49224342 100644 --- a/editor/Workspace/HierarchyView.axaml +++ b/editor/Workspace/HierarchyView.axaml @@ -85,6 +85,21 @@ + + @@ -142,7 +142,7 @@ + ToolTip.Tip="{Binding NodeName}" Classes="compact-text" /> + ToolTip.Tip="{Binding OperationText}" Classes="compact-text" /> + ToolTip.Tip="{Binding PreviousValue}" Classes="compact-text" /> + ToolTip.Tip="{Binding CurrentValue}" Classes="compact-text" /> @@ -199,4 +199,4 @@ - \ No newline at end of file + diff --git a/editor/Workspace/InspectorView.axaml b/editor/Workspace/InspectorView.axaml index 7d80eeefe..415e8c4b0 100644 --- a/editor/Workspace/InspectorView.axaml +++ b/editor/Workspace/InspectorView.axaml @@ -10,10 +10,11 @@ d:DesignWidth="300" d:DesignHeight="1000" x:Class="editor.Workspace.InspectorView" - Padding="8" + Padding="6" Background="{StaticResource Bg2}" x:DataType="vm:InspectorViewModel"> + @@ -21,13 +22,13 @@ - + @@ -97,6 +98,18 @@ + + @@ -216,8 +229,7 @@ - @@ -230,13 +242,13 @@ - - + Foreground="{StaticResource TextMuted}" Classes="compact-text" /> @@ -668,7 +682,7 @@ Background="{StaticResource Bg1}" Text="{Binding FilterText, Mode=TwoWay}" PlaceholderText="Filter parameters..." - PlaceholderForeground="{StaticResource TextMuted}" /> + PlaceholderForeground="{StaticResource TextMuted}" Classes="compact-control" /> @@ -684,17 +698,17 @@ Classes.warning="{Binding !Error}" Classes.error="{Binding Error}" CornerRadius="8" - Padding="8"> + Padding="6"> @@ -702,7 +716,7 @@ Foreground="{StaticResource Bg1}" FontWeight="Bold" TextWrapping="Wrap" - VerticalAlignment="Center" /> + VerticalAlignment="Center" Classes="compact-text" /> @@ -720,7 +734,7 @@ - @@ -731,7 +745,7 @@ HorizontalAlignment="Center" /> + TextAlignment="Center" Classes="compact-text" /> diff --git a/editor/Workspace/LabelWidthState.cs b/editor/Workspace/LabelWidthState.cs new file mode 100644 index 000000000..74e9d1600 --- /dev/null +++ b/editor/Workspace/LabelWidthState.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using editor.Assets; + +namespace editor.Workspace; + +public sealed class LabelWidthState { + private readonly string m_path; + private readonly Dictionary m_widths; + private static LabelWidthState? s_project; + private LabelWidthState(string path, Dictionary widths) { m_path = path; m_widths = widths; } + public static LabelWidthState Load(string path) { + try { + return new LabelWidthState(path, File.Exists(path) + ? JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? [] : []); + } catch { return new LabelWidthState(path, []); } + } + public static LabelWidthState? ForProject() { + if (!ProjectContext.IsInitialized) return null; + var path = ProjectContext.Resolve("cache://tools/label_widths.json"); + if (s_project?.m_path != path) s_project = Load(path); + return s_project; + } + public double? Get(string key) => m_widths.TryGetValue(key, out var width) && double.IsFinite(width) && width >= 0 ? width : null; + public void Set(string key, double width) { + if (!double.IsFinite(width) || width < 0) return; + m_widths[key] = width; + try { + Directory.CreateDirectory(Path.GetDirectoryName(m_path)!); + var temp = m_path + ".tmp." + Guid.NewGuid().ToString("N"); + File.WriteAllText(temp, JsonSerializer.Serialize(m_widths)); + File.Move(temp, m_path, true); + } catch { /* Best-effort editor state. */ } + } +} diff --git a/editor/Workspace/MainWindowViewModel.cs b/editor/Workspace/MainWindowViewModel.cs index a89caf1c9..274b61f8b 100644 --- a/editor/Workspace/MainWindowViewModel.cs +++ b/editor/Workspace/MainWindowViewModel.cs @@ -203,6 +203,9 @@ private void OnPlayModeChanged() { SaveAllNodesCommand.NotifyCanExecuteChanged(); PlayCommand.NotifyCanExecuteChanged(); PlayInWindowCommand.NotifyCanExecuteChanged(); + NewNodeCommand.NotifyCanExecuteChanged(); + OpenNodeFileCommand.NotifyCanExecuteChanged(); + foreach (var workspace in m_workspaces.Values) workspace.RefreshCanActivateTab(); } private IEnumerable EnumerateAutosavables() { @@ -403,6 +406,7 @@ private async void OnEditorOpenRequested(AssetFile file) { if (file.Definition is not { CanBeEdited: true } def) return; if (file.Uid is not { } uid) return; if (!AssetDatabase.TryResolve(uid, out var virtualPath, out _)) return; + if (def.EditorTool == "NodeEditor" && !CanOpenNodes()) return; // Opening a lockable asset nobody holds takes its lock if (def is not ProjectSettingsAsset && def.EditorTool != "NodeEditor") @@ -823,7 +827,12 @@ private async Task DeleteLayout(string? name) { if (string.Equals(ActiveLayoutName, name, StringComparison.Ordinal)) ResetLayout(); } - [RelayCommand] + // no tab may open under a running game + private static bool CanOpenNodes() { + return !WorkspaceViewModel.AnyPlayActive; + } + + [RelayCommand(CanExecute = nameof(CanOpenNodes))] private async Task NewNode() { if (App.MainWindow is not { } owner) return; var popup = new NodeTypeTree(); @@ -835,7 +844,7 @@ private async Task NewNode() { SyncActiveWorkspace(); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanOpenNodes))] private async Task OpenNodeFile() { if (App.MainWindow is not { } owner) return; var uid = await new AssetList("Node").ShowDialog(owner); diff --git a/editor/Workspace/ProjectSettingsView.axaml b/editor/Workspace/ProjectSettingsView.axaml index 4add50fee..7606d2beb 100644 --- a/editor/Workspace/ProjectSettingsView.axaml +++ b/editor/Workspace/ProjectSettingsView.axaml @@ -49,7 +49,7 @@ + Value="6" />