// This code is based on http://objectlistview.sourceforge.net (GPLv3 tree/list controls by phillip.piper@gmail.com). Phillip has explicitly granted permission for his design and code to be used in this library under the MIT license.
using System;
using System.Collections.Generic;
using System.Linq;
using NStack;
namespace Terminal.Gui {
///
/// Interface to implement when you want the regular (non generic) to automatically determine children for your class (without having to specify a )
///
public interface ITreeNode
{
///
/// Text to display when rendering the node
///
string Text {get;set;}
///
/// The children of your class which should be rendered underneath it when expanded
///
///
IList Children {get;}
///
/// Optionally allows you to store some custom data/class here.
///
object Tag {get;set;}
}
///
/// Simple class for representing nodes, use with regular (non generic) .
///
public class TreeNode : ITreeNode
{
///
/// Children of the current node
///
///
public IList Children {get;set;} = new List();
///
/// Text to display in tree node for current entry
///
///
public string Text {get;set;}
///
/// Optionally allows you to store some custom data/class here.
///
public object Tag {get;set;}
///
/// returns
///
///
public override string ToString()
{
return Text ?? "Unamed Node";
}
///
/// Initialises a new instance with no
///
public TreeNode()
{
}
///
/// Initialises a new instance and sets starting
///
public TreeNode(string text)
{
Text = text;
}
}
///
/// Interface for supplying data to a on demand as root level nodes are expanded by the user
///
public interface ITreeBuilder
{
///
/// Returns true if is implemented by this class
///
///
bool SupportsCanExpand {get;}
///
/// Returns true/false for whether a model has children. This method should be implemented when is an expensive operation otherwise should return false (in which case this method will not be called)
///
/// Only implement this method if you have a very fast way of determining whether an object can have children e.g. checking a Type (directories can always be expanded)
///
///
bool CanExpand(T model);
///
/// Returns all children of a given which should be added to the tree as new branches underneath it
///
///
///
IEnumerable GetChildren(T model);
}
///
/// Abstract implementation of .
///
public abstract class TreeBuilder : ITreeBuilder {
///
public bool SupportsCanExpand { get; protected set;} = false;
///
/// Override this method to return a rapid answer as to whether returns results. If you are implementing this method ensure you passed true in base constructor or set
///
///
///
public virtual bool CanExpand (T model){
return GetChildren(model).Any();
}
///
public abstract IEnumerable GetChildren (T model);
///
/// Constructs base and initializes
///
/// Pass true if you intend to implement otherwise false
public TreeBuilder(bool supportsCanExpand)
{
SupportsCanExpand = supportsCanExpand;
}
}
///
/// implementation for objects
///
public class TreeNodeBuilder : TreeBuilder
{
///
/// Initialises a new instance of builder for any model objects of Type
///
public TreeNodeBuilder():base(false)
{
}
///
/// Returns from
///
///
///
public override IEnumerable GetChildren (ITreeNode model)
{
return model.Children;
}
}
///
/// Implementation of that uses user defined functions
///
public class DelegateTreeBuilder : TreeBuilder
{
private Func> childGetter;
private Func canExpand;
///
/// Constructs an implementation of that calls the user defined method to determine children
///
///
///
public DelegateTreeBuilder(Func> childGetter) : base(false)
{
this.childGetter = childGetter;
}
///
/// Constructs an implementation of that calls the user defined method to determine children and to determine expandability
///
///
///
///
public DelegateTreeBuilder(Func> childGetter, Func canExpand) : base(true)
{
this.childGetter = childGetter;
this.canExpand = canExpand;
}
///
/// Returns whether a node can be expanded based on the delegate passed during construction
///
///
///
public override bool CanExpand (T model)
{
return canExpand?.Invoke(model) ?? base.CanExpand (model);
}
///
/// Returns children using the delegate method passed during construction
///
///
///
public override IEnumerable GetChildren (T model)
{
return childGetter.Invoke(model);
}
}
///
/// Interface for all non generic members of
///
public interface ITreeView {
///
/// Contains options for changing how the tree is rendered
///
TreeStyle Style{get;set;}
///
/// Removes all objects from the tree and clears selection
///
void ClearObjects ();
///
/// Sets a flag indicating this view needs to be redisplayed because its state has changed.
///
void SetNeedsDisplay ();
}
///
/// Convenience implementation of generic for any tree were all nodes implement
///
public class TreeView : TreeView {
///
/// Creates a new instance of the tree control with absolute positioning and initialises with default based builder
///
public TreeView ()
{
TreeBuilder = new TreeNodeBuilder();
AspectGetter = o=>o == null ? "Null" : (o.Text ?? o?.ToString() ?? "Unamed Node");
}
}
///
/// Defines rendering options that affect how the tree is displayed
///
public class TreeStyle {
///
/// True to render vertical lines under expanded nodes to show which node belongs to which parent. False to use only whitespace
///
///
public bool ShowBranchLines {get;set;} = true;
///
/// Symbol to use for branch nodes that can be expanded to indicate this to the user. Defaults to '+'. Set to null to hide
///
public Rune? ExpandableSymbol {get;set;} = '+';
///
/// Symbol to use for branch nodes that can be collapsed (are currently expanded). Defaults to '-'. Set to null to hide
///
public Rune? CollapseableSymbol {get;set;} = '-';
///
/// Set to true to highlight expand/collapse symbols in hot key color
///
public bool ColorExpandSymbol {get;set;}
///
/// Invert console colours used to render the expand symbol
///
public bool InvertExpandSymbolColors {get;set;}
///
/// True to leave the last row of the control free for overwritting (e.g. by a scrollbar). When True scrolling will be triggered on the second last row of the control rather than the last.
///
///
public bool LeaveLastRow {get;set;}
}
///
/// Hierarchical tree view with expandable branches. Branch objects are dynamically determined when expanded using a user defined
///
public class TreeView : View, ITreeView where T:class
{
private int scrollOffsetVertical;
private int scrollOffsetHorizontal;
///
/// Determines how sub branches of the tree are dynamically built at runtime as the user expands root nodes
///
///
public ITreeBuilder TreeBuilder { get;set;}
///
/// private variable for
///
T selectedObject;
///
/// Contains options for changing how the tree is rendered
///
public TreeStyle Style {get;set;} = new TreeStyle();
///
/// True to allow multiple objects to be selected at once
///
///
public bool MultiSelect {get;set;} = true;
///
/// The currently selected object in the tree. When is true this is the object at which the cursor is at
///
public T SelectedObject {
get => selectedObject;
set {
var oldValue = selectedObject;
selectedObject = value;
if(!ReferenceEquals(oldValue,value))
OnSelectionChanged(new SelectionChangedEventArgs(this,oldValue,value));
}
}
///
/// Secondary selected regions of tree when is true
///
private Stack> multiSelectedRegions = new Stack>();
///
/// Cached result of
///
private Branch[] cachedLineMap;
///
/// Error message to display when the control is not properly initialized at draw time (nodes added but no tree builder set)
///
public static ustring NoBuilderError = "ERROR: Builder Not Set";
///
/// Called when the changes
///
public event EventHandler> SelectionChanged;
///
/// The root objects in the tree, note that this collection is of root objects only
///
public IEnumerable Objects {get=>roots.Keys;}
///
/// Map of root objects to the branches under them. All objects have a even if that branch has no children
///
internal Dictionary> roots {get; set;} = new Dictionary>();
///
/// The amount of tree view that has been scrolled off the top of the screen (by the user scrolling down)
///
/// Setting a value of less than 0 will result in a offset of 0. To see changes in the UI call
public int ScrollOffsetVertical {
get => scrollOffsetVertical;
set {
scrollOffsetVertical = Math.Max(0,value);
}
}
///
/// The amount of tree view that has been scrolled to the right (horizontally)
///
/// Setting a value of less than 0 will result in a offset of 0. To see changes in the UI call
public int ScrollOffsetHorizontal {
get => scrollOffsetHorizontal;
set {
scrollOffsetHorizontal = Math.Max(0,value);
}
}
///
/// The current number of rows in the tree (ignoring the controls bounds)
///
public int ContentHeight => BuildLineMap().Count();
///
/// Returns the string representation of model objects hosted in the tree. Default implementation is to call
///
///
public AspectGetterDelegate AspectGetter {get;set;} = (o)=>o.ToString() ?? "";
///
/// Creates a new tree view with absolute positioning. Use to set set root objects for the tree. Children will not be rendered until you set
///
public TreeView():base()
{
CanFocus = true;
}
///
/// Initialises .Creates a new tree view with absolute positioning. Use to set set root objects for the tree.
///
public TreeView(ITreeBuilder builder) : this()
{
TreeBuilder = builder;
}
///
/// Adds a new root level object unless it is already a root of the tree
///
///
public void AddObject(T o)
{
if(!roots.ContainsKey(o)) {
roots.Add(o,new Branch(this,null,o));
InvalidateLineMap();
SetNeedsDisplay();
}
}
///
/// Removes all objects from the tree and clears
///
public void ClearObjects()
{
SelectedObject = default(T);
multiSelectedRegions.Clear();
roots = new Dictionary>();
InvalidateLineMap();
SetNeedsDisplay();
}
///
/// Removes the given root object from the tree
///
/// If is the currently then the selection is cleared
///
public void Remove(T o)
{
if(roots.ContainsKey(o)) {
roots.Remove(o);
InvalidateLineMap();
SetNeedsDisplay();
if(Equals(SelectedObject,o))
SelectedObject = default(T);
}
}
///
/// Adds many new root level objects. Objects that are already root objects are ignored
///
/// Objects to add as new root level objects
public void AddObjects(IEnumerable collection)
{
bool objectsAdded = false;
foreach(var o in collection) {
if (!roots.ContainsKey (o)) {
roots.Add(o,new Branch(this,null,o));
objectsAdded = true;
}
}
if (objectsAdded) {
InvalidateLineMap();
SetNeedsDisplay();
}
}
///
/// Refreshes the state of the object in the tree. This will recompute children, string representation etc
///
/// This has no effect if the object is not exposed in the tree.
///
/// True to also refresh all ancestors of the objects branch (starting with the root). False to refresh only the passed node
public void RefreshObject (T o, bool startAtTop = false)
{
var branch = ObjectToBranch(o);
if(branch != null) {
branch.Refresh(startAtTop);
InvalidateLineMap();
SetNeedsDisplay();
}
}
///
/// Rebuilds the tree structure for all exposed objects starting with the root objects. Call this method when you know there are changes to the tree but don't know which objects have changed (otherwise use )
///
public void RebuildTree()
{
foreach(var branch in roots.Values)
branch.Rebuild();
InvalidateLineMap();
SetNeedsDisplay();
}
///
/// Returns the currently expanded children of the passed object. Returns an empty collection if the branch is not exposed or not expanded
///
/// An object in the tree
///
public IEnumerable GetChildren (T o)
{
var branch = ObjectToBranch(o);
if(branch == null || !branch.IsExpanded)
return new T[0];
return branch.ChildBranches?.Values?.Select(b=>b.Model)?.ToArray() ?? new T[0];
}
///
/// Returns the parent object of in the tree. Returns null if the object is not exposed in the tree
///
/// An object in the tree
///
public T GetParent (T o)
{
return ObjectToBranch(o)?.Parent?.Model;
}
///
public override void Redraw (Rect bounds)
{
if(roots == null)
return;
if(TreeBuilder == null) {
Move(0,0);
Driver.AddStr(NoBuilderError);
return;
}
var map = BuildLineMap();
for(int line = 0 ; line < bounds.Height; line++){
var idxToRender = ScrollOffsetVertical + line;
// Is there part of the tree view to render?
if(idxToRender < map.Length) {
// Render the line
map[idxToRender].Draw(Driver,ColorScheme,line,bounds.Width);
} else {
// Else clear the line to prevent stale symbols due to scrolling etc
Move(0,line);
Driver.SetAttribute(ColorScheme.Normal);
Driver.AddStr(new string(' ',bounds.Width));
}
}
}
///
/// Returns the index of the object if it is currently exposed (it's parent(s) have been expanded). This can be used with and to scroll to a specific object
///
/// Uses the Equals method and returns the first index at which the object is found or -1 if it is not found
/// An object that appears in your tree and is currently exposed
/// The index the object was found at or -1 if it is not currently revealed or not in the tree at all
public int GetScrollOffsetOf(T o)
{
var map = BuildLineMap();
for (int i = 0; i < map.Length; i++)
{
if (map[i].Model.Equals(o))
return i;
}
//object not found
return -1;
}
///
/// Returns the maximum width line in the tree including prefix and expansion symbols
///
/// True to consider only rows currently visible (based on window bounds and . False to calculate the width of every exposed branch in the tree
///
public int GetContentWidth(bool visible){
var map = BuildLineMap();
if(map.Length == 0)
return 0;
if(visible){
//Somehow we managed to scroll off the end of the control
if(ScrollOffsetVertical >= map.Length)
return 0;
// If control has no height to it then there is no visible area for content
if(Bounds.Height == 0)
return 0;
return map.Skip(ScrollOffsetVertical).Take(Bounds.Height).Max(b=>b.GetWidth(Driver));
}
else{
return map.Max(b=>b.GetWidth(Driver));
}
}
///
/// Calculates all currently visible/expanded branches (including leafs) and outputs them by index from the top of the screen
///
/// Index 0 of the returned array is the first item that should be visible in the top of the control, index 1 is the next etc.
///
private Branch[] BuildLineMap()
{
if(cachedLineMap != null)
return cachedLineMap;
List> toReturn = new List>();
foreach(var root in roots.Values) {
toReturn.AddRange(AddToLineMap(root));
}
return cachedLineMap = toReturn.ToArray();
}
private IEnumerable> AddToLineMap (Branch currentBranch)
{
yield return currentBranch;
if(currentBranch.IsExpanded){
foreach(var subBranch in currentBranch.ChildBranches.Values){
foreach(var sub in AddToLineMap(subBranch)) {
yield return sub;
}
}
}
}
///
public override bool ProcessKey (KeyEvent keyEvent)
{
switch (keyEvent.Key) {
case Key.CursorRight:
Expand(SelectedObject);
break;
case Key.CursorRight | Key.CtrlMask:
ExpandAll(SelectedObject);
break;
case Key.CursorLeft:
case Key.CursorLeft | Key.CtrlMask:
CursorLeft(keyEvent.Key.HasFlag(Key.CtrlMask));
break;
case Key.CursorUp:
case Key.CursorUp | Key.ShiftMask:
AdjustSelection(-1,keyEvent.Key.HasFlag(Key.ShiftMask));
break;
case Key.CursorDown:
case Key.CursorDown | Key.ShiftMask:
AdjustSelection(1,keyEvent.Key.HasFlag(Key.ShiftMask));
break;
case Key.PageUp:
case Key.PageUp | Key.ShiftMask:
AdjustSelection(-Bounds.Height,keyEvent.Key.HasFlag(Key.ShiftMask));
break;
case Key.PageDown:
case Key.PageDown | Key.ShiftMask:
AdjustSelection(Bounds.Height,keyEvent.Key.HasFlag(Key.ShiftMask));
break;
case Key.A | Key.CtrlMask:
SelectAll();
break;
case Key.Home:
GoToFirst();
break;
case Key.End:
GoToEnd();
break;
default:
// we don't care about this keystroke
return false;
}
PositionCursor ();
return true;
}
///
public override bool MouseEvent (MouseEvent me)
{
if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) && !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) &&
me.Flags != MouseFlags.WheeledDown && me.Flags != MouseFlags.WheeledUp && me.Flags != MouseFlags.WheeledRight&& me.Flags != MouseFlags.WheeledLeft)
return false;
if (!HasFocus && CanFocus) {
SetFocus ();
}
if (me.Flags == MouseFlags.WheeledDown) {
ScrollOffsetVertical++;
SetNeedsDisplay();
return true;
} else if (me.Flags == MouseFlags.WheeledUp) {
ScrollOffsetVertical--;
SetNeedsDisplay();
return true;
}
if (me.Flags == MouseFlags.WheeledRight) {
ScrollOffsetHorizontal++;
SetNeedsDisplay();
return true;
} else if (me.Flags == MouseFlags.WheeledLeft) {
ScrollOffsetHorizontal--;
SetNeedsDisplay();
return true;
}
if(me.Flags.HasFlag(MouseFlags.Button1Clicked)) {
var map = BuildLineMap();
var idx = me.Y + ScrollOffsetVertical;
// click is outside any visible nodes
if(idx < 0 || idx >= map.Length) {
return false;
}
// The line they clicked on
var clickedBranch = map[idx];
bool isExpandToggleAttempt = clickedBranch.IsHitOnExpandableSymbol(Driver,me.X);
// If we are already selected (double click)
if(Equals(SelectedObject,clickedBranch.Model))
isExpandToggleAttempt = true;
// if they clicked on the +/- expansion symbol
if( isExpandToggleAttempt) {
if (clickedBranch.IsExpanded) {
clickedBranch.Collapse();
}
else
if(clickedBranch.CanExpand())
clickedBranch.Expand();
else {
SelectedObject = clickedBranch.Model; // It is a leaf node
multiSelectedRegions.Clear();
}
}
else {
// It is a first click somewhere in the current line that doesn't look like an expansion/collapse attempt
SelectedObject = clickedBranch.Model;
multiSelectedRegions.Clear();
}
SetNeedsDisplay();
return true;
}
return false;
}
///
/// Positions the cursor at the start of the selected objects line (if visible)
///
public override void PositionCursor()
{
if (CanFocus && HasFocus && Visible && SelectedObject != null)
{
var map = BuildLineMap();
var idx = Array.FindIndex(map,b=>b.Model.Equals(SelectedObject));
// if currently selected line is visible
if(idx - ScrollOffsetVertical >= 0 && idx - ScrollOffsetVertical < Bounds.Height)
Move(0,idx - ScrollOffsetVertical);
else
base.PositionCursor();
} else {
base.PositionCursor();
}
}
///
/// Determines systems behaviour when the left arrow key is pressed. Default behaviour is to collapse the current tree node if possible otherwise changes selection to current branches parent
///
protected virtual void CursorLeft(bool ctrl)
{
if(IsExpanded(SelectedObject)) {
if(ctrl)
CollapseAll(SelectedObject);
else
Collapse(SelectedObject);
}
else
{
var parent = GetParent(SelectedObject);
if(parent != null){
SelectedObject = parent;
AdjustSelection(0);
SetNeedsDisplay();
}
}
}
///
/// Changes the to the first root object and resets the to 0
///
public void GoToFirst()
{
ScrollOffsetVertical = 0;
SelectedObject = roots.Keys.FirstOrDefault();
SetNeedsDisplay();
}
///
/// Changes the to the last object in the tree and scrolls so that it is visible
///
public void GoToEnd ()
{
var map = BuildLineMap();
ScrollOffsetVertical = Math.Max(0,map.Length - Bounds.Height +1);
SelectedObject = map.Last().Model;
SetNeedsDisplay();
}
///
/// Changes the selected object by a number of screen lines
///
/// If nothing is currently selected the first root is selected. If the selected object is no longer in the tree the first object is selected
///
/// True to expand the selection (assuming is enabled). False to replace
public void AdjustSelection (int offset, bool expandSelection = false)
{
// if it is not a shift click or we don't allow multi select
if(!expandSelection || !MultiSelect)
multiSelectedRegions.Clear();
if(SelectedObject == null){
SelectedObject = roots.Keys.FirstOrDefault();
}
else {
var map = BuildLineMap();
var idx = Array.FindIndex(map,b=>b.Model.Equals(SelectedObject));
if(idx == -1) {
// The current selection has disapeared!
SelectedObject = roots.Keys.FirstOrDefault();
}
else {
var newIdx = Math.Min(Math.Max(0,idx+offset),map.Length-1);
var newBranch = map[newIdx];
// If it is a multi selection
if(expandSelection && MultiSelect)
{
if(multiSelectedRegions.Any())
{
// expand the existing head selection
var head = multiSelectedRegions.Pop();
multiSelectedRegions.Push(new TreeSelection(head.Origin,newIdx,map));
}
else
{
// or start a new multi selection region
multiSelectedRegions.Push(new TreeSelection(map[idx],newIdx,map));
}
}
SelectedObject = newBranch.Model;
/*this -1 allows for possible horizontal scroll bar in the last row of the control*/
int leaveSpace = Style.LeaveLastRow ? 1 :0;
if(newIdx < ScrollOffsetVertical) {
//if user has scrolled up too far to see their selection
ScrollOffsetVertical = newIdx;
}
else if(newIdx >= ScrollOffsetVertical + Bounds.Height - leaveSpace){
//if user has scrolled off bottom of visible tree
ScrollOffsetVertical = Math.Max(0,(newIdx+1) - (Bounds.Height-leaveSpace));
}
}
}
InvalidateLineMap();
SetNeedsDisplay();
}
///
/// Expands the supplied object if it is contained in the tree (either as a root object or as an exposed branch object)
///
/// The object to expand
public void Expand(T toExpand)
{
if(toExpand == null)
return;
ObjectToBranch(toExpand)?.Expand();
InvalidateLineMap();
SetNeedsDisplay();
}
///
/// Expands the supplied object and all child objects
///
/// The object to expand
public void ExpandAll(T toExpand)
{
if(toExpand == null)
return;
ObjectToBranch(toExpand)?.ExpandAll();
InvalidateLineMap();
SetNeedsDisplay();
}
///
/// Fully expands all nodes in the tree, if the tree is very big and built dynamically this may take a while (e.g. for file system)
///
public void ExpandAll()
{
foreach (var item in roots) {
item.Value.ExpandAll();
}
InvalidateLineMap();
SetNeedsDisplay();
}
///
/// Returns true if the given object is exposed in the tree and can be expanded otherwise false
///
///
///
public bool CanExpand(T o)
{
return ObjectToBranch(o)?.CanExpand() ?? false;
}
///
/// Returns true if the given object is exposed in the tree and expanded otherwise false
///
///
///
public bool IsExpanded(T o)
{
return ObjectToBranch(o)?.IsExpanded ?? false;
}
///
/// Collapses the supplied object if it is currently expanded
///
/// The object to collapse
public void Collapse(T toCollapse)
{
CollapseImpl(toCollapse,false);
}
///
/// Collapses the supplied object if it is currently expanded. Also collapses all children branches (this will only become apparent when/if the user expands it again)
///
/// The object to collapse
public void CollapseAll(T toCollapse)
{
CollapseImpl(toCollapse,true);
}
///
/// Collapses all root nodes in the tree
///
public void CollapseAll()
{
foreach (var item in roots) {
item.Value.Collapse();
}
InvalidateLineMap();
SetNeedsDisplay();
}
///
/// Implementation of and . Performs operation and updates selection if disapeared
///
///
///
protected void CollapseImpl(T toCollapse, bool all)
{
if(toCollapse == null)
return;
var branch = ObjectToBranch(toCollapse);
// Nothing to collapse
if(branch == null)
return;
if (all) {
branch.CollapseAll();
} else {
branch.Collapse();
}
if(SelectedObject != null && ObjectToBranch(SelectedObject) == null)
{
// If the old selection suddenly became invalid then clear it
SelectedObject = null;
}
InvalidateLineMap();
SetNeedsDisplay();
}
///
/// Clears any cached results of
///
protected void InvalidateLineMap()
{
cachedLineMap = null;
}
///
/// Returns the corresponding in the tree for . This will not work for objects hidden by their parent being collapsed
///
///
/// The branch for or null if it is not currently exposed in the tree
private Branch ObjectToBranch(T toFind)
{
return BuildLineMap().FirstOrDefault(o=>o.Model.Equals(toFind));
}
///
/// Returns true if the is either the or part of a
///
///
///
public bool IsSelected (T model)
{
return Equals(SelectedObject , model) ||
(MultiSelect && multiSelectedRegions.Any(s=>s.Contains(model)));
}
///
/// Returns (if not null) and all multi selected objects if is true
///
///
public IEnumerable GetAllSelectedObjects()
{
var map = BuildLineMap();
if(SelectedObject != null)
yield return SelectedObject;
// To determine multi selected objects, start with the line map, that avoids yielding hidden nodes that were selected then the parent collapsed e.g. programmatically or with mouse click
if(MultiSelect){
foreach(var m in map.Select(b=>b.Model).Where(IsSelected)){
if(m != SelectedObject){
yield return m;
}
}
}
}
///
/// Selects all objects in the tree when is enabled otherwise does nothing
///
public void SelectAll()
{
if(!MultiSelect)
return;
multiSelectedRegions.Clear();
var map = BuildLineMap();
if(map.Length == 0)
return;
multiSelectedRegions.Push(new TreeSelection(map[0],map.Length,map));
SetNeedsDisplay();
OnSelectionChanged(new SelectionChangedEventArgs(this,SelectedObject,SelectedObject));
}
///
/// Raises the SelectionChanged event
///
///
protected virtual void OnSelectionChanged (SelectionChangedEventArgs e)
{
SelectionChanged?.Invoke(this,e);
}
}
class TreeSelection where T : class {
public Branch Origin {get;}
private HashSet included = new HashSet();
///
/// Creates a new selection between two branches in the tree
///
///
///
///
public TreeSelection(Branch from, int toIndex, Branch[] map )
{
Origin = from;
included.Add(Origin.Model);
var oldIdx = Array.IndexOf(map,from);
var lowIndex = Math.Min(oldIdx,toIndex);
var highIndex = Math.Max(oldIdx,toIndex);
// Select everything between the old and new indexes
foreach(var alsoInclude in map.Skip(lowIndex).Take(highIndex-lowIndex)){
included.Add(alsoInclude.Model);
}
}
public bool Contains(T model)
{
return included.Contains(model);
}
}
class Branch where T:class
{
///
/// True if the branch is expanded to reveal child branches
///
public bool IsExpanded {get;set;}
///
/// The users object that is being displayed by this branch of the tree
///
public T Model {get;private set;}
///
/// The depth of the current branch. Depth of 0 indicates root level branches
///
public int Depth {get;private set;} = 0;
///
/// The children of the current branch. This is null until the first call to to avoid enumerating the entire underlying hierarchy
///
public Dictionary> ChildBranches {get;set;}
///
/// The parent or null if it is a root.
///
public Branch Parent {get; private set;}
private TreeView tree;
///
/// Declares a new branch of in which the users object is presented
///
/// The UI control in which the branch resides
/// Pass null for root level branches, otherwise pass the parent
/// The user's object that should be displayed
public Branch(TreeView tree,Branch parentBranchIfAny,T model)
{
this.tree = tree;
this.Model = model;
if(parentBranchIfAny != null) {
Depth = parentBranchIfAny.Depth +1;
Parent = parentBranchIfAny;
}
}
///
/// Fetch the children of this branch. This method populates
///
public virtual void FetchChildren()
{
if (tree.TreeBuilder == null)
return;
var children = tree.TreeBuilder.GetChildren(this.Model) ?? Enumerable.Empty();
this.ChildBranches = children.ToDictionary(k=>k,val=>new Branch(tree,this,val));
}
///
/// Returns the width of the line including prefix and the results of (the line body).
///
///
public virtual int GetWidth (ConsoleDriver driver)
{
return
GetLinePrefix(driver).Sum(Rune.ColumnWidth) +
Rune.ColumnWidth(GetExpandableSymbol(driver)) +
(tree.AspectGetter(Model) ?? "").Length;
}
///
/// Renders the current on the specified line
///
///
///
///
///
public virtual void Draw(ConsoleDriver driver,ColorScheme colorScheme, int y, int availableWidth)
{
// true if the current line of the tree is the selected one and control has focus
bool isSelected = tree.IsSelected(Model) && tree.HasFocus;
Attribute lineColor = isSelected? colorScheme.Focus : colorScheme.Normal;
driver.SetAttribute(lineColor);
// Everything on line before the expansion run and branch text
Rune[] prefix = GetLinePrefix(driver).ToArray();
Rune expansion = GetExpandableSymbol(driver);
string lineBody = tree.AspectGetter(Model) ?? "";
tree.Move(0,y);
// if we have scrolled to the right then bits of the prefix will have dispeared off the screen
int toSkip = tree.ScrollOffsetHorizontal;
// Draw the line prefix (all paralell lanes or whitespace and an expand/collapse/leaf symbol)
foreach(Rune r in prefix){
if(toSkip > 0){
toSkip--;
}
else{
driver.AddRune(r);
availableWidth -= Rune.ColumnWidth(r);
}
}
// pick color for expanded symbol
if(tree.Style.ColorExpandSymbol || tree.Style.InvertExpandSymbolColors)
{
Attribute color;
if(tree.Style.ColorExpandSymbol)
color = isSelected ? tree.ColorScheme.HotFocus : tree.ColorScheme.HotNormal;
else
color = lineColor;
if(tree.Style.InvertExpandSymbolColors)
color = new Attribute(color.Background,color.Foreground);
driver.SetAttribute(color);
}
if(toSkip > 0){
toSkip--;
}
else{
driver.AddRune(expansion);
availableWidth -= Rune.ColumnWidth(expansion);
}
// horizontal scrolling has already skipped the prefix but now must also skip some of the line body
if(toSkip > 0)
{
if(toSkip > lineBody.Length){
lineBody = "";
}
else{
lineBody = lineBody.Substring(toSkip);
}
}
// If body of line is too long
if(lineBody.Sum(l=>Rune.ColumnWidth(l)) > availableWidth)
{
// remaining space is zero and truncate the line
lineBody = new string(lineBody.TakeWhile(c=>(availableWidth -= Rune.ColumnWidth(c)) >= 0).ToArray());
availableWidth = 0;
}
else{
// line is short so remaining width will be whatever comes after the line body
availableWidth -= lineBody.Length;
}
//reset the line color if it was changed for rendering expansion symbol
driver.SetAttribute(lineColor);
driver.AddStr(lineBody);
if(availableWidth > 0)
driver.AddStr(new string(' ',availableWidth));
driver.SetAttribute(colorScheme.Normal);
}
///
/// Gets all characters to render prior to the current branches line. This includes indentation whitespace and any tree branches (if enabled)
///
///
///
private IEnumerable GetLinePrefix (ConsoleDriver driver)
{
// If not showing line branches or this is a root object
if (!tree.Style.ShowBranchLines) {
for(int i = 0; i < Depth; i++) {
yield return new Rune(' ');
}
yield break;
}
// yield indentations with runes appropriate to the state of the parents
foreach(var cur in GetParentBranches().Reverse())
{
if(cur.IsLast())
yield return new Rune(' ');
else
yield return driver.VLine;
yield return new Rune(' ');
}
if(IsLast())
yield return driver.LLCorner;
else
yield return driver.LeftTee;
}
///
/// Returns all parents starting with the immediate parent and ending at the root
///
///
private IEnumerable> GetParentBranches()
{
var cur = Parent;
while(cur != null)
{
yield return cur;
cur = cur.Parent;
}
}
///
/// Returns an appropriate symbol for displaying next to the string representation of the object to indicate whether it or not (or it is a leaf)
///
///
///
public Rune GetExpandableSymbol(ConsoleDriver driver)
{
var leafSymbol = tree.Style.ShowBranchLines ? driver.HLine : ' ';
if(IsExpanded)
return tree.Style.CollapseableSymbol ?? leafSymbol;
if(CanExpand())
return tree.Style.ExpandableSymbol ?? leafSymbol;
return leafSymbol;
}
///
/// Returns true if the current branch can be expanded according to the or cached children already fetched
///
///
public bool CanExpand ()
{
// if we do not know the children yet
if(ChildBranches == null) {
//if there is a rapid method for determining whether there are children
if(tree.TreeBuilder.SupportsCanExpand) {
return tree.TreeBuilder.CanExpand(Model);
}
//there is no way of knowing whether we can expand without fetching the children
FetchChildren();
}
//we fetched or already know the children, so return whether we have any
return ChildBranches.Any();
}
///
/// Expands the current branch if possible
///
public void Expand()
{
if(ChildBranches == null) {
FetchChildren();
}
if (ChildBranches.Any ()) {
IsExpanded = true;
}
}
///
/// Marks the branch as collapsed ( false)
///
public void Collapse ()
{
IsExpanded = false;
}
///
/// Refreshes cached knowledge in this branch e.g. what children an object has
///
/// True to also refresh all branches (starting with the root)
public void Refresh (bool startAtTop)
{
// if we must go up and refresh from the top down
if(startAtTop)
Parent?.Refresh(true);
// we don't want to loose the state of our children so lets be selective about how we refresh
//if we don't know about any children yet just use the normal method
if(ChildBranches == null)
FetchChildren();
else {
// we already knew about some children so preserve the state of the old children
// first gather the new Children
var newChildren = tree.TreeBuilder?.GetChildren(this.Model) ?? Enumerable.Empty();
// Children who no longer appear need to go
foreach(var toRemove in ChildBranches.Keys.Except(newChildren).ToArray())
{
ChildBranches.Remove(toRemove);
//also if the user has this node selected (its disapearing) so lets change selection to us (the parent object) to be helpful
if(Equals(tree.SelectedObject ,toRemove))
tree.SelectedObject = Model;
}
// New children need to be added
foreach(var newChild in newChildren)
{
// If we don't know about the child yet we need a new branch
if (!ChildBranches.ContainsKey (newChild)) {
ChildBranches.Add(newChild,new Branch(tree,this,newChild));
}
else{
//we already have this object but update the reference anyway incase Equality match but the references are new
ChildBranches[newChild].Model = newChild;
}
}
}
}
///
/// Calls on the current branch and all expanded children
///
internal void Rebuild()
{
Refresh(false);
// if we know about our children
if(ChildBranches != null) {
if(IsExpanded) {
//if we are expanded we need to updatethe visible children
foreach(var child in ChildBranches) {
child.Value.Rebuild();
}
}
else {
// we are not expanded so should forget about children because they may not exist anymore
ChildBranches = null;
}
}
}
///
/// Returns true if this branch has parents and it is the last node of it's parents branches (or last root of the tree)
///
///
private bool IsLast()
{
if(Parent == null)
return this == tree.roots.Values.LastOrDefault();
return Parent.ChildBranches.Values.LastOrDefault() == this;
}
///
/// Returns true if the given x offset on the branch line is the +/- symbol. Returns false if not showing expansion symbols or leaf node etc
///
///
///
///
internal bool IsHitOnExpandableSymbol (ConsoleDriver driver, int x)
{
// if leaf node then we cannot expand
if(!CanExpand())
return false;
// if we could theoretically expand
if(!IsExpanded && tree.Style.ExpandableSymbol != null) {
return x == GetLinePrefix(driver).Count();
}
// if we could theoretically collapse
if(IsExpanded && tree.Style.CollapseableSymbol != null) {
return x == GetLinePrefix(driver).Count();
}
return false;
}
///
/// Expands the current branch and all children branches
///
internal void ExpandAll ()
{
Expand();
if(ChildBranches != null)
foreach (var child in ChildBranches) {
child.Value.ExpandAll();
}
}
///
/// Collapses the current branch and all children branches (even though those branches are no longer visible they retain collapse/expansion state)
///
internal void CollapseAll ()
{
Collapse();
if(ChildBranches != null)
foreach (var child in ChildBranches) {
child.Value.CollapseAll();
}
}
}
///
/// Delegates of this type are used to fetch string representations of user's model objects
///
///
///
public delegate string AspectGetterDelegate(T model) where T:class;
///
/// Event arguments describing a change in selected object in a tree view
///
public class SelectionChangedEventArgs : EventArgs where T:class
{
///
/// The view in which the change occurred
///
public TreeView Tree { get; }
///
/// The previously selected value (can be null)
///
public T OldValue { get; }
///
/// The newly selected value in the (can be null)
///
public T NewValue { get; }
///
/// Creates a new instance of event args describing a change of selection in
///
///
///
///
public SelectionChangedEventArgs(TreeView tree, T oldValue, T newValue)
{
Tree = tree;
OldValue = oldValue;
NewValue = newValue;
}
}
}