Fixes #4009 - fix tree ordering (#4015)

This commit is contained in:
Thomas Nind
2025-04-05 13:47:39 +01:00
committed by Tig
parent 47833bfc80
commit 4d87d5f249
11 changed files with 356 additions and 110 deletions

View File

@@ -87,8 +87,8 @@ public class TreeTableSource<T> : IEnumerableTableSource<T>, IDisposable where T
Branch<T> branch = RowToBranch (row); Branch<T> branch = RowToBranch (row);
// Everything on line before the expansion run and branch text // Everything on line before the expansion run and branch text
Rune [] prefix = branch.GetLinePrefix (Application.Driver).ToArray (); Rune [] prefix = branch.GetLinePrefix ().ToArray ();
Rune expansion = branch.GetExpandableSymbol (Application.Driver); Rune expansion = branch.GetExpandableSymbol ();
string lineBody = _tree.AspectGetter (branch.Model) ?? ""; string lineBody = _tree.AspectGetter (branch.Model) ?? "";
var sb = new StringBuilder (); var sb = new StringBuilder ();

View File

@@ -1,8 +1,10 @@
namespace Terminal.Gui; #nullable enable
namespace Terminal.Gui;
internal class Branch<T> where T : class internal class Branch<T> where T : class
{ {
private readonly TreeView<T> tree; private readonly TreeView<T> _tree;
/// <summary> /// <summary>
/// Declares a new branch of <paramref name="tree"/> in which the users object <paramref name="model"/> is /// Declares a new branch of <paramref name="tree"/> in which the users object <paramref name="model"/> is
@@ -11,9 +13,9 @@ internal class Branch<T> where T : class
/// <param name="tree">The UI control in which the branch resides.</param> /// <param name="tree">The UI control in which the branch resides.</param>
/// <param name="parentBranchIfAny">Pass null for root level branches, otherwise pass the parent.</param> /// <param name="parentBranchIfAny">Pass null for root level branches, otherwise pass the parent.</param>
/// <param name="model">The user's object that should be displayed.</param> /// <param name="model">The user's object that should be displayed.</param>
public Branch (TreeView<T> tree, Branch<T> parentBranchIfAny, T model) public Branch (TreeView<T> tree, Branch<T>? parentBranchIfAny, T model)
{ {
this.tree = tree; _tree = tree;
Model = model; Model = model;
if (parentBranchIfAny is { }) if (parentBranchIfAny is { })
@@ -27,7 +29,7 @@ internal class Branch<T> where T : class
/// The children of the current branch. This is null until the first call to <see cref="FetchChildren"/> to avoid /// The children of the current branch. This is null until the first call to <see cref="FetchChildren"/> to avoid
/// enumerating the entire underlying hierarchy. /// enumerating the entire underlying hierarchy.
/// </summary> /// </summary>
public Dictionary<T, Branch<T>> ChildBranches { get; set; } public List<Branch<T>>? ChildBranches { get; set; }
/// <summary>The depth of the current branch. Depth of 0 indicates root level branches.</summary> /// <summary>The depth of the current branch. Depth of 0 indicates root level branches.</summary>
public int Depth { get; } public int Depth { get; }
@@ -39,7 +41,7 @@ internal class Branch<T> where T : class
public T Model { get; private set; } public T Model { get; private set; }
/// <summary>The parent <see cref="Branch{T}"/> or null if it is a root.</summary> /// <summary>The parent <see cref="Branch{T}"/> or null if it is a root.</summary>
public Branch<T> Parent { get; } public Branch<T>? Parent { get; }
/// <summary> /// <summary>
/// Returns true if the current branch can be expanded according to the <see cref="TreeBuilder{T}"/> or cached /// Returns true if the current branch can be expanded according to the <see cref="TreeBuilder{T}"/> or cached
@@ -52,13 +54,13 @@ internal class Branch<T> where T : class
if (ChildBranches is null) if (ChildBranches is null)
{ {
//if there is a rapid method for determining whether there are children //if there is a rapid method for determining whether there are children
if (tree.TreeBuilder.SupportsCanExpand) if (_tree.TreeBuilder.SupportsCanExpand)
{ {
return tree.TreeBuilder.CanExpand (Model); return _tree.TreeBuilder.CanExpand (Model);
} }
//there is no way of knowing whether we can expand without fetching the children //there is no way of knowing whether we can expand without fetching the children
FetchChildren (); ChildBranches = FetchChildren ();
} }
//we fetched or already know the children, so return whether we have any //we fetched or already know the children, so return whether we have any
@@ -69,32 +71,30 @@ internal class Branch<T> where T : class
public void Collapse () { IsExpanded = false; } public void Collapse () { IsExpanded = false; }
/// <summary>Renders the current <see cref="Model"/> on the specified line <paramref name="y"/>.</summary> /// <summary>Renders the current <see cref="Model"/> on the specified line <paramref name="y"/>.</summary>
/// <param name="driver"></param>
/// <param name="colorScheme"></param>
/// <param name="y"></param> /// <param name="y"></param>
/// <param name="availableWidth"></param> /// <param name="availableWidth"></param>
public virtual void Draw (IConsoleDriver driver, ColorScheme colorScheme, int y, int availableWidth) public virtual void Draw (int y, int availableWidth)
{ {
List<Cell> cells = new (); List<Cell> cells = new ();
int? indexOfExpandCollapseSymbol = null; int? indexOfExpandCollapseSymbol = null;
int indexOfModelText; int indexOfModelText;
// true if the current line of the tree is the selected one and control has focus // true if the current line of the tree is the selected one and control has focus
bool isSelected = tree.IsSelected (Model); bool isSelected = _tree.IsSelected (Model);
Attribute textColor = Attribute textColor =
isSelected ? tree.HasFocus ? colorScheme.Focus : colorScheme.HotNormal : colorScheme.Normal; isSelected ? _tree.HasFocus ? _tree.GetFocusColor () : _tree.GetHotNormalColor () : _tree.GetNormalColor ();
Attribute symbolColor = tree.Style.HighlightModelTextOnly ? colorScheme.Normal : textColor; Attribute symbolColor = _tree.Style.HighlightModelTextOnly ? _tree.GetNormalColor () : textColor;
// Everything on line before the expansion run and branch text // Everything on line before the expansion run and branch text
Rune [] prefix = GetLinePrefix (driver).ToArray (); Rune [] prefix = GetLinePrefix ().ToArray ();
Rune expansion = GetExpandableSymbol (driver); Rune expansion = GetExpandableSymbol ();
string lineBody = tree.AspectGetter (Model) ?? ""; string lineBody = _tree.AspectGetter (Model) ?? "";
tree.Move (0, y); _tree.Move (0, y);
// if we have scrolled to the right then bits of the prefix will have disappeared off the screen // if we have scrolled to the right then bits of the prefix will have disappeared off the screen
int toSkip = tree.ScrollOffsetHorizontal; int toSkip = _tree.ScrollOffsetHorizontal;
Attribute attr = symbolColor; Attribute attr = symbolColor;
// Draw the line prefix (all parallel lanes or whitespace and an expand/collapse/leaf symbol) // Draw the line prefix (all parallel lanes or whitespace and an expand/collapse/leaf symbol)
@@ -112,20 +112,20 @@ internal class Branch<T> where T : class
} }
// pick color for expanded symbol // pick color for expanded symbol
if (tree.Style.ColorExpandSymbol || tree.Style.InvertExpandSymbolColors) if (_tree.Style.ColorExpandSymbol || _tree.Style.InvertExpandSymbolColors)
{ {
Attribute color = symbolColor; Attribute color;
if (tree.Style.ColorExpandSymbol) if (_tree.Style.ColorExpandSymbol)
{ {
if (isSelected) if (isSelected)
{ {
color = tree.Style.HighlightModelTextOnly ? colorScheme.HotNormal : color = _tree.Style.HighlightModelTextOnly ? _tree.GetHotNormalColor () :
tree.HasFocus ? tree.ColorScheme.HotFocus : tree.ColorScheme.HotNormal; _tree.HasFocus ? _tree.GetHotFocusColor () : _tree.GetHotNormalColor ();
} }
else else
{ {
color = tree.ColorScheme.HotNormal; color = _tree.GetHotNormalColor ();
} }
} }
else else
@@ -133,9 +133,9 @@ internal class Branch<T> where T : class
color = symbolColor; color = symbolColor;
} }
if (tree.Style.InvertExpandSymbolColors) if (_tree.Style.InvertExpandSymbolColors)
{ {
color = new Attribute (color.Background, color.Foreground); color = new (color.Background, color.Foreground);
} }
attr = color; attr = color;
@@ -177,10 +177,10 @@ internal class Branch<T> where T : class
if (lineBody.EnumerateRunes ().Sum (l => l.GetColumns ()) > availableWidth) if (lineBody.EnumerateRunes ().Sum (l => l.GetColumns ()) > availableWidth)
{ {
// remaining space is zero and truncate the line // remaining space is zero and truncate the line
lineBody = new string ( lineBody = new (
lineBody.TakeWhile (c => (availableWidth -= ((Rune)c).GetColumns ()) >= 0) lineBody.TakeWhile (c => (availableWidth -= ((Rune)c).GetColumns ()) >= 0)
.ToArray () .ToArray ()
); );
availableWidth = 0; availableWidth = 0;
} }
else else
@@ -194,9 +194,9 @@ internal class Branch<T> where T : class
Attribute modelColor = textColor; Attribute modelColor = textColor;
// if custom color delegate invoke it // if custom color delegate invoke it
if (tree.ColorGetter is { }) if (_tree.ColorGetter is { })
{ {
ColorScheme modelScheme = tree.ColorGetter (Model); ColorScheme modelScheme = _tree.ColorGetter (Model);
// if custom color scheme is defined for this Model // if custom color scheme is defined for this Model
if (modelScheme is { }) if (modelScheme is { })
@@ -206,12 +206,12 @@ internal class Branch<T> where T : class
} }
else else
{ {
modelColor = new Attribute (); modelColor = new ();
} }
} }
attr = modelColor; attr = modelColor;
cells.AddRange (lineBody.Select (r => NewCell (attr, new Rune (r)))); cells.AddRange (lineBody.Select (r => NewCell (attr, new (r))));
if (availableWidth > 0) if (availableWidth > 0)
{ {
@@ -219,7 +219,7 @@ internal class Branch<T> where T : class
cells.AddRange ( cells.AddRange (
Enumerable.Repeat ( Enumerable.Repeat (
NewCell (attr, new Rune (' ')), NewCell (attr, new (' ')),
availableWidth availableWidth
) )
); );
@@ -230,32 +230,29 @@ internal class Branch<T> where T : class
Model = Model, Model = Model,
Y = y, Y = y,
Cells = cells, Cells = cells,
Tree = tree, Tree = _tree,
IndexOfExpandCollapseSymbol = IndexOfExpandCollapseSymbol =
indexOfExpandCollapseSymbol, indexOfExpandCollapseSymbol,
IndexOfModelText = indexOfModelText IndexOfModelText = indexOfModelText
}; };
tree.OnDrawLine (e); _tree.OnDrawLine (e);
if (!e.Handled && driver != null) if (!e.Handled)
{ {
foreach (Cell cell in cells) foreach (Cell cell in cells)
{ {
driver.SetAttribute ((Attribute)cell.Attribute!); _tree.SetAttribute ((Attribute)cell.Attribute!);
driver.AddRune (cell.Rune); _tree.AddRune (cell.Rune);
} }
} }
driver?.SetAttribute (colorScheme.Normal); _tree.SetAttribute (_tree.GetNormalColor());
} }
/// <summary>Expands the current branch if possible.</summary> /// <summary>Expands the current branch if possible.</summary>
public void Expand () public void Expand ()
{ {
if (ChildBranches is null) ChildBranches ??= FetchChildren ();
{
FetchChildren ();
}
if (ChildBranches.Any ()) if (ChildBranches.Any ())
{ {
@@ -264,45 +261,44 @@ internal class Branch<T> where T : class
} }
/// <summary>Fetch the children of this branch. This method populates <see cref="ChildBranches"/>.</summary> /// <summary>Fetch the children of this branch. This method populates <see cref="ChildBranches"/>.</summary>
public virtual void FetchChildren () private List<Branch<T>> FetchChildren ()
{ {
if (tree.TreeBuilder is null) if (_tree.TreeBuilder is null)
{ {
return; return [];
} }
IEnumerable<T> children; IEnumerable<T> children;
if (Depth >= tree.MaxDepth) if (Depth >= _tree.MaxDepth)
{ {
children = Enumerable.Empty<T> (); children = [];
} }
else else
{ {
children = tree.TreeBuilder.GetChildren (Model) ?? Enumerable.Empty<T> (); children = _tree.TreeBuilder.GetChildren (Model) ?? [];
} }
ChildBranches = children.ToDictionary (k => k, val => new Branch<T> (tree, this, val)); return children.Select (o => new Branch<T> (_tree, this, o)).ToList ();
} }
/// <summary> /// <summary>
/// Returns an appropriate symbol for displaying next to the string representation of the <see cref="Model"/> /// Returns an appropriate symbol for displaying next to the string representation of the <see cref="Model"/>
/// object to indicate whether it <see cref="IsExpanded"/> or not (or it is a leaf). /// object to indicate whether it <see cref="IsExpanded"/> or not (or it is a leaf).
/// </summary> /// </summary>
/// <param name="driver"></param>
/// <returns></returns> /// <returns></returns>
public Rune GetExpandableSymbol (IConsoleDriver driver) public Rune GetExpandableSymbol ()
{ {
Rune leafSymbol = tree.Style.ShowBranchLines ? Glyphs.HLine : (Rune)' '; Rune leafSymbol = _tree.Style.ShowBranchLines ? Glyphs.HLine : (Rune)' ';
if (IsExpanded) if (IsExpanded)
{ {
return tree.Style.CollapseableSymbol ?? leafSymbol; return _tree.Style.CollapseableSymbol ?? leafSymbol;
} }
if (CanExpand ()) if (CanExpand ())
{ {
return tree.Style.ExpandableSymbol ?? leafSymbol; return _tree.Style.ExpandableSymbol ?? leafSymbol;
} }
return leafSymbol; return leafSymbol;
@@ -313,10 +309,10 @@ internal class Branch<T> where T : class
/// line body). /// line body).
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public virtual int GetWidth (IConsoleDriver driver) public virtual int GetWidth ()
{ {
return return
GetLinePrefix (driver).Sum (r => r.GetColumns ()) + GetExpandableSymbol (driver).GetColumns () + (tree.AspectGetter (Model) ?? "").Length; GetLinePrefix ().Sum (r => r.GetColumns ()) + GetExpandableSymbol ().GetColumns () + (_tree.AspectGetter (Model) ?? "").Length;
} }
/// <summary>Refreshes cached knowledge in this branch e.g. what children an object has.</summary> /// <summary>Refreshes cached knowledge in this branch e.g. what children an object has.</summary>
@@ -333,41 +329,46 @@ internal class Branch<T> where T : class
//if we don't know about any children yet just use the normal method //if we don't know about any children yet just use the normal method
if (ChildBranches is null) if (ChildBranches is null)
{ {
FetchChildren (); ChildBranches = FetchChildren ();
} }
else else
{ {
// we already knew about some children so preserve the state of the old children // we already knew about some children so preserve the state of the old children
// first gather the new Children // first gather the new Children
IEnumerable<T> newChildren = tree.TreeBuilder?.GetChildren (Model) ?? Enumerable.Empty<T> (); T [] newChildren = _tree.TreeBuilder?.GetChildren (Model).ToArray () ?? [];
// Children who no longer appear need to go // Children who no longer appear need to go
foreach (T toRemove in ChildBranches.Keys.Except (newChildren).ToArray ()) foreach (Branch<T> toRemove in ChildBranches.Where (b => !newChildren.Contains (b.Model)).ToArray ())
{ {
ChildBranches.Remove (toRemove); ChildBranches.Remove (toRemove);
//also if the user has this node selected (its disappearing) so lets change selection to us (the parent object) to be helpful //also if the user has this node selected (its disappearing) so lets change selection to us (the parent object) to be helpful
if (Equals (tree.SelectedObject, toRemove)) if (Equals (_tree.SelectedObject, toRemove.Model))
{ {
tree.SelectedObject = Model; _tree.SelectedObject = Model;
} }
} }
// New children need to be added // New children need to be added
foreach (T newChild in newChildren) foreach (T newChild in newChildren)
{ {
Branch<T>? existingBranch = ChildBranches.FirstOrDefault (b => b.Model.Equals (newChild));
// If we don't know about the child, yet we need a new branch // If we don't know about the child, yet we need a new branch
if (!ChildBranches.ContainsKey (newChild)) if (existingBranch == null)
{ {
ChildBranches.Add (newChild, new Branch<T> (tree, this, newChild)); ChildBranches.Add (new (_tree, this, newChild));
} }
else else
{ {
//we already have this object but update the reference anyway in case Equality match but the references are new //we already have this object but update the reference anyway in case Equality match but the references are new
ChildBranches [newChild].Model = newChild; existingBranch.Model = newChild;
} }
} }
// Order the list
ChildBranches = ChildBranches.OrderBy (b => newChildren.IndexOf (b.Model)).ToList ();
} }
} }
@@ -381,9 +382,9 @@ internal class Branch<T> where T : class
if (ChildBranches is { }) if (ChildBranches is { })
{ {
foreach (KeyValuePair<T, Branch<T>> child in ChildBranches) foreach (Branch<T> child in ChildBranches)
{ {
child.Value.CollapseAll (); child.CollapseAll ();
} }
} }
} }
@@ -395,9 +396,9 @@ internal class Branch<T> where T : class
if (ChildBranches is { }) if (ChildBranches is { })
{ {
foreach (KeyValuePair<T, Branch<T>> child in ChildBranches) foreach (Branch<T> child in ChildBranches)
{ {
child.Value.ExpandAll (); child.ExpandAll ();
} }
} }
} }
@@ -406,16 +407,15 @@ internal class Branch<T> where T : class
/// Gets all characters to render prior to the current branches line. This includes indentation whitespace and /// Gets all characters to render prior to the current branches line. This includes indentation whitespace and
/// any tree branches (if enabled). /// any tree branches (if enabled).
/// </summary> /// </summary>
/// <param name="driver"></param>
/// <returns></returns> /// <returns></returns>
internal IEnumerable<Rune> GetLinePrefix (IConsoleDriver driver) internal IEnumerable<Rune> GetLinePrefix ()
{ {
// If not showing line branches or this is a root object. // If not showing line branches or this is a root object.
if (!tree.Style.ShowBranchLines) if (!_tree.Style.ShowBranchLines)
{ {
for (var i = 0; i < Depth; i++) for (var i = 0; i < Depth; i++)
{ {
yield return new Rune (' '); yield return new (' ');
} }
yield break; yield break;
@@ -426,14 +426,14 @@ internal class Branch<T> where T : class
{ {
if (cur.IsLast ()) if (cur.IsLast ())
{ {
yield return new Rune (' '); yield return new (' ');
} }
else else
{ {
yield return Glyphs.VLine; yield return Glyphs.VLine;
} }
yield return new Rune (' '); yield return new (' ');
} }
if (IsLast ()) if (IsLast ())
@@ -462,15 +462,15 @@ internal class Branch<T> where T : class
} }
// if we could theoretically expand // if we could theoretically expand
if (!IsExpanded && tree.Style.ExpandableSymbol != default (Rune?)) if (!IsExpanded && _tree.Style.ExpandableSymbol != default (Rune?))
{ {
return x == GetLinePrefix (driver).Count (); return x == GetLinePrefix ().Count ();
} }
// if we could theoretically collapse // if we could theoretically collapse
if (IsExpanded && tree.Style.CollapseableSymbol != default (Rune?)) if (IsExpanded && _tree.Style.CollapseableSymbol != default (Rune?))
{ {
return x == GetLinePrefix (driver).Count (); return x == GetLinePrefix ().Count ();
} }
return false; return false;
@@ -487,9 +487,9 @@ internal class Branch<T> where T : class
if (IsExpanded) if (IsExpanded)
{ {
// if we are expanded we need to update the visible children // if we are expanded we need to update the visible children
foreach (KeyValuePair<T, Branch<T>> child in ChildBranches) foreach (Branch<T> child in ChildBranches)
{ {
child.Value.Rebuild (); child.Rebuild ();
} }
} }
else else
@@ -504,7 +504,7 @@ internal class Branch<T> where T : class
/// <returns></returns> /// <returns></returns>
private IEnumerable<Branch<T>> GetParentBranches () private IEnumerable<Branch<T>> GetParentBranches ()
{ {
Branch<T> cur = Parent; Branch<T>? cur = Parent;
while (cur is { }) while (cur is { })
{ {
@@ -523,11 +523,13 @@ internal class Branch<T> where T : class
{ {
if (Parent is null) if (Parent is null)
{ {
return this == tree.roots.Values.LastOrDefault (); return this == _tree.roots.Values.LastOrDefault ();
} }
return Parent.ChildBranches.Values.LastOrDefault () == this; Parent.ChildBranches ??= Parent.FetchChildren ();
return Parent.ChildBranches.LastOrDefault () == this;
} }
private static Cell NewCell (Attribute attr, Rune r) { return new Cell { Rune = r, Attribute = new (attr) }; } private static Cell NewCell (Attribute attr, Rune r) { return new() { Rune = r, Attribute = new (attr) }; }
} }

View File

@@ -847,7 +847,7 @@ public class TreeView<T> : View, ITreeView where T : class
return new T [0]; return new T [0];
} }
return branch.ChildBranches?.Values?.Select (b => b.Model)?.ToArray () ?? new T [0]; return branch.ChildBranches?.Select (b => b.Model)?.ToArray () ?? new T [0];
} }
/// <summary>Returns the maximum width line in the tree including prefix and expansion symbols.</summary> /// <summary>Returns the maximum width line in the tree including prefix and expansion symbols.</summary>
@@ -879,10 +879,10 @@ public class TreeView<T> : View, ITreeView where T : class
return 0; return 0;
} }
return map.Skip (ScrollOffsetVertical).Take (Viewport.Height).Max (b => b.GetWidth (Driver)); return map.Skip (ScrollOffsetVertical).Take (Viewport.Height).Max (b => b.GetWidth ());
} }
return map.Max (b => b.GetWidth (Driver)); return map.Max (b => b.GetWidth ());
} }
/// <summary> /// <summary>
@@ -1171,7 +1171,7 @@ public class TreeView<T> : View, ITreeView where T : class
if (idxToRender < map.Count) if (idxToRender < map.Count)
{ {
// Render the line // Render the line
map.ElementAt (idxToRender).Draw (Driver, ColorScheme, line, Viewport.Width); map.ElementAt (idxToRender).Draw (line, Viewport.Width);
} }
else else
{ {
@@ -1488,7 +1488,7 @@ public class TreeView<T> : View, ITreeView where T : class
if (currentBranch.IsExpanded) if (currentBranch.IsExpanded)
{ {
foreach (Branch<T> subBranch in currentBranch.ChildBranches.Values) foreach (Branch<T> subBranch in currentBranch.ChildBranches)
{ {
foreach (Branch<T> sub in AddToLineMap (subBranch, weMatch, out bool childMatch)) foreach (Branch<T> sub in AddToLineMap (subBranch, weMatch, out bool childMatch))
{ {

View File

@@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests.Parallelizable",
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerminalGuiFluentTesting", "TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj", "{2DBA7BDC-17AE-474B-A507-00807D087607}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerminalGuiFluentTesting", "TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj", "{2DBA7BDC-17AE-474B-A507-00807D087607}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerminalGuiFluentTesting.Xunit", "TerminalGuiFluentTesting.Xunit\TerminalGuiFluentTesting.Xunit.csproj", "{231B9723-10F3-46DB-8EAE-50C0C0375AD3}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -123,6 +125,10 @@ Global
{2DBA7BDC-17AE-474B-A507-00807D087607}.Debug|Any CPU.Build.0 = Debug|Any CPU {2DBA7BDC-17AE-474B-A507-00807D087607}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2DBA7BDC-17AE-474B-A507-00807D087607}.Release|Any CPU.ActiveCfg = Release|Any CPU {2DBA7BDC-17AE-474B-A507-00807D087607}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2DBA7BDC-17AE-474B-A507-00807D087607}.Release|Any CPU.Build.0 = Release|Any CPU {2DBA7BDC-17AE-474B-A507-00807D087607}.Release|Any CPU.Build.0 = Release|Any CPU
{231B9723-10F3-46DB-8EAE-50C0C0375AD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{231B9723-10F3-46DB-8EAE-50C0C0375AD3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{231B9723-10F3-46DB-8EAE-50C0C0375AD3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{231B9723-10F3-46DB-8EAE-50C0C0375AD3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj" />
<PackageReference Include="xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
using Xunit;
namespace TerminalGuiFluentTesting;
public static class XunitContextExtensions
{
public static GuiTestContext AssertTrue (this GuiTestContext context, bool? condition)
{
context.Then (
() =>
{
Assert.True (condition);
});
return context;
}
public static GuiTestContext AssertEqual (this GuiTestContext context, object? expected, object? actual)
{
context.Then (
() =>
{
Assert.Equal (expected,actual);
});
return context;
}
}

View File

@@ -243,7 +243,18 @@ public class GuiTestContext : IDisposable
/// <returns></returns> /// <returns></returns>
public GuiTestContext Then (Action doAction) public GuiTestContext Then (Action doAction)
{ {
doAction (); try
{
doAction ();
}
catch(Exception)
{
Stop ();
_hardStop.Cancel();
throw;
}
return this; return this;
} }
@@ -360,6 +371,7 @@ public class GuiTestContext : IDisposable
{ {
SendNetKey (k); SendNetKey (k);
} }
WaitIteration ();
break; break;
default: default:
throw new ArgumentOutOfRangeException (); throw new ArgumentOutOfRangeException ();
@@ -550,4 +562,25 @@ public class GuiTestContext : IDisposable
WaitIteration (); WaitIteration ();
} }
/// <summary>
/// Sets the input focus to the given <see cref="View"/>.
/// Throws <see cref="ArgumentException"/> if focus did not change due to system
/// constraints e.g. <paramref name="toFocus"/>
/// <see cref="View.CanFocus"/> is <see langword="false"/>
/// </summary>
/// <param name="toFocus"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
public GuiTestContext Focus (View toFocus)
{
toFocus.FocusDeepest (NavigationDirection.Forward, TabBehavior.TabStop);
if (!toFocus.HasFocus)
{
throw new ArgumentException ("Failed to set focus, FocusDeepest did not result in HasFocus becoming true. Ensure view is added and focusable");
}
return WaitIteration ();
}
} }

View File

@@ -1,5 +1,4 @@
using System.Text; using Terminal.Gui;
using Terminal.Gui;
using TerminalGuiFluentTesting; using TerminalGuiFluentTesting;
using Xunit.Abstractions; using Xunit.Abstractions;
@@ -9,17 +8,6 @@ public class BasicFluentAssertionTests
{ {
private readonly TextWriter _out; private readonly TextWriter _out;
public class TestOutputWriter : TextWriter
{
private readonly ITestOutputHelper _output;
public TestOutputWriter (ITestOutputHelper output) { _output = output; }
public override void WriteLine (string? value) { _output.WriteLine (value ?? string.Empty); }
public override Encoding Encoding => Encoding.UTF8;
}
public BasicFluentAssertionTests (ITestOutputHelper outputHelper) { _out = new TestOutputWriter (outputHelper); } public BasicFluentAssertionTests (ITestOutputHelper outputHelper) { _out = new TestOutputWriter (outputHelper); }
[Theory] [Theory]

View File

@@ -0,0 +1,15 @@
using System.Text;
using Xunit.Abstractions;
namespace IntegrationTests.FluentTests;
public class TestOutputWriter : TextWriter
{
private readonly ITestOutputHelper _output;
public TestOutputWriter (ITestOutputHelper output) { _output = output; }
public override void WriteLine (string? value) { _output.WriteLine (value ?? string.Empty); }
public override Encoding Encoding => Encoding.UTF8;
}

View File

@@ -0,0 +1,162 @@
using Terminal.Gui;
using TerminalGuiFluentTesting;
using Xunit.Abstractions;
namespace IntegrationTests.FluentTests;
public class TreeViewFluentTests
{
private readonly TextWriter _out;
public TreeViewFluentTests (ITestOutputHelper outputHelper) { _out = new TestOutputWriter (outputHelper); }
[Theory]
[ClassData (typeof (V2TestDrivers))]
public void TreeView_AllowReOrdering (V2TestDriver d)
{
var tv = new TreeView
{
Width = Dim.Fill (),
Height = Dim.Fill ()
};
TreeNode car;
TreeNode lorry;
TreeNode bike;
var root = new TreeNode ("Root")
{
Children =
[
car = new ("Car"),
lorry = new ("Lorry"),
bike = new ("Bike")
]
};
tv.AddObject (root);
using GuiTestContext context =
With.A<Window> (40, 10, d)
.Add (tv)
.Focus (tv)
.WaitIteration ()
.ScreenShot ("Before expanding", _out)
.AssertEqual (root, tv.GetObjectOnRow (0))
.Then (() => Assert.Null (tv.GetObjectOnRow (1)))
.Right ()
.ScreenShot ("After expanding", _out)
.AssertEqual (root, tv.GetObjectOnRow (0))
.AssertEqual (car, tv.GetObjectOnRow (1))
.AssertEqual (lorry, tv.GetObjectOnRow (2))
.AssertEqual (bike, tv.GetObjectOnRow (3))
.Then (
() =>
{
// Re order
root.Children = [bike, car, lorry];
tv.RefreshObject (root);
})
.WaitIteration ()
.ScreenShot ("After re-order", _out)
.AssertEqual (root, tv.GetObjectOnRow (0))
.AssertEqual (bike, tv.GetObjectOnRow (1))
.AssertEqual (car, tv.GetObjectOnRow (2))
.AssertEqual (lorry, tv.GetObjectOnRow (3))
.WriteOutLogs (_out);
context.Stop ();
}
[Theory]
[ClassData (typeof (V2TestDrivers))]
public void TreeViewReOrder_PreservesExpansion (V2TestDriver d)
{
var tv = new TreeView
{
Width = Dim.Fill (),
Height = Dim.Fill ()
};
TreeNode car;
TreeNode lorry;
TreeNode bike;
TreeNode mrA;
TreeNode mrB;
TreeNode mrC;
TreeNode mrD;
TreeNode mrE;
var root = new TreeNode ("Root")
{
Children =
[
car = new ("Car")
{
Children =
[
mrA = new ("Mr A"),
mrB = new ("Mr B")
]
},
lorry = new ("Lorry")
{
Children =
[
mrC = new ("Mr C")
]
},
bike = new ("Bike")
{
Children =
[
mrD = new ("Mr D"),
mrE = new ("Mr E")
]
}
]
};
tv.AddObject (root);
tv.ExpandAll ();
using GuiTestContext context =
With.A<Window> (40, 13, d)
.Add (tv)
.WaitIteration ()
.ScreenShot ("Initial State", _out)
.AssertEqual (root, tv.GetObjectOnRow (0))
.AssertEqual (car, tv.GetObjectOnRow (1))
.AssertEqual (mrA, tv.GetObjectOnRow (2))
.AssertEqual (mrB, tv.GetObjectOnRow (3))
.AssertEqual (lorry, tv.GetObjectOnRow (4))
.AssertEqual (mrC, tv.GetObjectOnRow (5))
.AssertEqual (bike, tv.GetObjectOnRow (6))
.AssertEqual (mrD, tv.GetObjectOnRow (7))
.AssertEqual (mrE, tv.GetObjectOnRow (8))
.Then (
() =>
{
// Re order
root.Children = [bike, car, lorry];
tv.RefreshObject (root);
})
.WaitIteration ()
.ScreenShot ("After re-order", _out)
.AssertEqual (root, tv.GetObjectOnRow (0))
.AssertEqual (bike, tv.GetObjectOnRow (1))
.AssertEqual (mrD, tv.GetObjectOnRow (2))
.AssertEqual (mrE, tv.GetObjectOnRow (3))
.AssertEqual (car, tv.GetObjectOnRow (4))
.AssertEqual (mrA, tv.GetObjectOnRow (5))
.AssertEqual (mrB, tv.GetObjectOnRow (6))
.AssertEqual (lorry, tv.GetObjectOnRow (7))
.AssertEqual (mrC, tv.GetObjectOnRow (8))
.WriteOutLogs (_out);
context.Stop ();
}
}

View File

@@ -26,6 +26,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" /> <ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />
<ProjectReference Include="..\..\TerminalGuiFluentTesting.Xunit\TerminalGuiFluentTesting.Xunit.csproj" />
<ProjectReference Include="..\..\TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj" /> <ProjectReference Include="..\..\TerminalGuiFluentTesting\TerminalGuiFluentTesting.csproj" />
<ProjectReference Include="..\..\UICatalog\UICatalog.csproj" /> <ProjectReference Include="..\..\UICatalog\UICatalog.csproj" />
<ProjectReference Include="..\UnitTests\UnitTests.csproj" /> <ProjectReference Include="..\UnitTests\UnitTests.csproj" />