Gumby.TreeView.psm1

using module Gumby.Debug
using module Gumby.Log
using module Gumby.ListBox
using module Gumby.Window

class TVItemBase : LBItemBase {
    TVItemBase() {}

    hidden TVItemBase([uint32] $level) {
        $this._level = $level
    }

    [uint32] Level() {return $this._level }
    [bool] IsContainer() { throw "abstract" }
    [bool] IsExpanded() { return $this._isExpanded }
    [TVItemBase] Parent() { throw "abstract" }
    [Collections.Generic.IList`1[TVItemBase]] Children() { throw "abstract" }
    [void] Expand() {
        if (!$this.IsContainer()) {
            $errorMessage = "Item `"$($this.Name())`" is not a container."
            [Log]::Error($errorMessage)
            throw $errorMessage
        }
        if ($this.IsExpanded()) {
            $errorMessage = "Item `"$($this.Name())`" is already expanded."
            [Log]::Error($errorMessage)
            throw $errorMessage
        }
        $this._isExpanded = $true
    }
    [void] Collapse() {
        if (!$this.IsExpanded()) {
            $errorMessage = "Item `"$($this.Name())`" is not expanded."
            [Log]::Error($errorMessage)
            throw $errorMessage
        }
        $this._isExpanded = $false
    }

    hidden [bool] $_isExpanded = $false
    hidden [uint32] $_level = 0
}

class SimpleObjectTVItem : TVItemBase {
    SimpleObjectTVItem([object] $simpleObject) {
        $this._simpleObject = $simpleObject
    }

    hidden SimpleObjectTVItem([object] $simpleObject, [TVItemBase] $parent, [uint32] $level) : base($level) {
        $this._simpleObject = $simpleObject
        $this._parent = $parent
    }

    [string] Name() { return $this._simpleObject.Name }

    [object] Value() { return $this._simpleObject }

    [bool] IsContainer() {
        return $this._simpleObject.ContainsKey('Children') -and
            $this._simpleObject.Children.Count -gt 0
    }

    [TVItemBase] Parent() {return $this._parent }

    [Collections.Generic.IList`1[TVItemBase]] Children() {
        if ($this._children -eq $null) {
            if ($this._simpleObject.ContainsKey('Children')) {
                $this._children = [Collections.Generic.List`1[TVItemBase]]::new($this._simpleObject.Children.Count)
                foreach ($simpleChild in $this._simpleObject.Children) {
                    $this._children.Add([SimpleObjectTVItem]::new($simpleChild, $this, $this.Level() + 1)) | Out-Null
                }
            } else {
                $this._children = [Collections.Generic.List`1[TVItemBase]]::new()
            }
        }

        return $this._children
    }

    hidden [object] $_simpleObject
    hidden [TVItemBase] $_parent = $null
    hidden [Collections.Generic.IList`1[TVItemBase]] $_children = $null
}

class FileTVItem : TVItemBase {
    FileTVItem([IO.FileSystemInfo] $fsInfo) {
        $this._fsInfo = $fsInfo
        # splitting the full name is between 4 and 6 times faster than a parent walk
        $this._level = $fsInfo.FullName.Split((PathSeparator)).Count - 1
    }

    hidden FileTVItem([IO.FileSystemInfo] $fsInfo, [uint32] $level) : base($level) {
        $this._fsInfo = $fsInfo
    }

    [string] Name() { return $this._fsInfo.Name }

    [object] Value() { return $this._fsInfo }

    [bool] IsContainer() {
        return ($this._fsInfo -is [System.IO.DirectoryInfo])
    }

    [TVItemBase] Parent() {
        $fsParent = if ($this._fsInfo -is [System.IO.DirectoryInfo]) {
            $this._fsInfo.Parent
        } else {
            $this._fsInfo.Directory
        }

        if ($fsParent -ne $null) {
            return [FileTVItem]::new($fsParent, $this.Level() - 1)
        } else {
            return $null
        }
    }

    [Collections.Generic.IList`1[TVItemBase]] Children() {

        # Be aware that FS items retrieved via PS drive provider commands (e.g. Get-Item or
        # Get-ChildItem) are annotated with 'PS*' properties (e.g. PSIsContainer). FS items
        # retrieved via "native" .NET methods or properties do not have these annotations.
        # Therefore, mixing drive provider and native retrieval methods can result in incongruous
        # objects and obscure bugs. Better exclusively use one or the other kind of retrieval
        # methods.

        if ($this._children -eq $null) {

            $this._children = [Collections.Generic.List`1[TVItemBase]]::new()

            $fsDirInfo = $this._fsInfo -as [System.IO.DirectoryInfo]

            if ($fsDirInfo -ne $null) {
                foreach ($fsChildDirInfo in $fsDirInfo.GetDirectories()) {
                    if (($fsChildDirInfo.Attributes -band ([System.IO.FileAttributes]::Hidden)) -eq ([System.IO.FileAttributes]::Hidden)) { continue }
                    if (($fsChildDirInfo.Attributes -band ([System.IO.FileAttributes]::System)) -eq ([System.IO.FileAttributes]::System)) { continue }
        
                    $this._children.Add([FileTVItem]::new($fsChildDirInfo, $this.Level() + 1))
                }
                foreach ($fsChildFileInfo in $fsDirInfo.GetFiles()) {
                    if (($fsChildFileInfo.Attributes -band ([System.IO.FileAttributes]::Hidden)) -eq ([System.IO.FileAttributes]::Hidden)) { continue }
                    if (($fsChildFileInfo.Attributes -band ([System.IO.FileAttributes]::System)) -eq ([System.IO.FileAttributes]::System)) { continue }
                    $this._children.Add([FileTVItem]::new($fsChildFileInfo, $this.Level() + 1))
                }
            }
        }

        return $this._children
    }

    hidden [IO.FileSystemInfo] $_fsInfo
    hidden [TVItemBase] $_parent = $null
    hidden [Collections.Generic.IList`1[TVItemBase]] $_children = $null
}

class TreeView : ListBox {
    <# const #> [uint32] $MaxLevelCount = 4

    TreeView(
        [int] $left,
        [int] $top,
        [int] $width,
        [int] $height,
        [ConsoleColor] $foregroundColor = $Global:Host.UI.RawUI.BackgroundColor,
        [ConsoleColor] $backgroundColor = $Global:Host.UI.RawUI.ForegroundColor
    ) : base($left, $top, $width, $height, $foregroundColor, $backgroundColor) {
    }

    TreeView(
        [System.Collections.IEnumerable] $items,
        [System.Reflection.TypeInfo] $tvItemType,
        [int] $left,
        [int] $top,
        [int] $width,
        [int] $height,
        [ConsoleColor] $foregroundColor = $Global:Host.UI.RawUI.BackgroundColor,
        [ConsoleColor] $backgroundColor = $Global:Host.UI.RawUI.ForegroundColor
    ) : base($left, $top, $width, $height, $foregroundColor, $backgroundColor) {

        # We cannot pass the items collection to the base class as it would layout the text prior
        # to having determined the top indentation level.

        $i = 0
        $tvItems = [System.Collections.Generic.List`1[LBItemBase]]::new()
        foreach ($item in $items) {
            $tvItem = $tvItemType::new($item)
            $tvItems.Add($tvItem) | Out-Null

            if ($i++ -eq 0) {
                $this.topLevelInView = $tvItem.Level()
            } else {
                Assert ($tvItem.Level() -eq $this.topLevelInView)
            }
        }

        $this.InitializeItems($tvItems)
    }

    # for debugging purposes
    hidden [void] TraceItems() {
        for ($i = 0; $i -lt $this.ItemCount(); ++$i) {
            $item = $this.GetItem($i)
            
            if ($item.IsExpanded()) { $expansionState = "expanded" } else { $expansionState = "collapsed" }
            if ($i -eq $this.FirstRowInView) { $firstInView = ", firstInView" } else { $firstInView = "" }
            if ($i -eq $this.SelectedIndex()) { $selected = ", selected" } else { $selected = "" }

            [Log]::Trace(("{0}: N=`"{1}`"; L={2}; {3}{4}{5}" -f
                $i,
                $this.GetItemLabel($item),
                $item.Level(),
                $expansionState,
                $firstInView,
                $selected))
        }
    }

    # for debugging purposes
    [string] TraceInfo() {
        return "Name=`"$($this.SelectedItem().Name())`"; SIx=$($this.SelectedIndex()); FRIV=$($this.FirstRowInView); TLIV=$($this.topLevelInView)"
    }

    <#
    .PARAMETER item
    The item to get a label for. The type of this parameter is 'object' rather than a more specific
    type like 'LBItemBase' or 'TVItemBase' to avoid method overloading and allow for method
    overriding.
    #>

    [string] GetItemLabel([object] $item) {
        [char] $icon = if ($item.IsContainer()) {
            if ($item.IsExpanded()) {
                <# black down-pointing triangle #> 0x25BC
            } else {
                <# black right-pointing pointer #> 0x25BA
            }
        } else {
            # <# black square #> 0x25A0
            <# black small square #> 0x25AA
        }

        return ((' ' * 4 * ($item.Level() - $this.topLevelInView)) + $icon + ' ' + $item.Name())
    }

    [void] RemoveItem([int] $index) {
        if ($this.GetItem($index).IsExpanded()) {
            # otherwise the icon is wrong if we re-use the item
            $this.GetItem($index).Collapse()
        }
        ([ListBox]$this).RemoveItem($index)
    }

    [void] OnKey([System.ConsoleKeyInfo] $key) {
        [Log]::Trace("TV.OnKey: Name=`"$($this.SelectedItem().Name())`"; SII=$($this.SelectedIndex()); K=$($key.Key)")

        switch ($key.Key) {
            ([ConsoleKey]::RightArrow) {
                $this.Expand()
            }

            ([ConsoleKey]::LeftArrow) {
                $this.Collapse()
            }

            default {
                ([ListBox]$this).OnKey($key)
            }
        }
    }

    <#
    .SYNOPSIS
    Gets the range of items that are - along the ancestor axis - within a "level distance" of a
    start item.
 
    .PARAMETER startIndex
    Index of the item to get range for.
 
    .PARAMETER levelDistance
    Number of levels determining the size of the range (relative to the level of the start item).
 
    .DESCRIPTION
    Given the tree ...
 
        0 1 2 3 4 level
            |<--:---| level distance 2
    00: A1 | : |
    01: B1 : | <- first return value
    02: B2 : |
    03: C1 |
    04: D1
    05: | E1
    06: C2 |
    07: D2
    08: D3 <- startIndex
    09: D4
    10: C3
    11: B3 <- second return value
    12: A2
 
    ... calling this method the start item index 8 (item D3) and a level distance of 2 would return
    (1, 11).
 
    .OUTPUTS
    Pair of item indices, the first marking the start of the range, the second its end.
    #>

    [int[]] GetAncestralSiblingRange([uint32] $startIndex, [uint32] $levelDistance) {
        function GetFirstAtLevel([uint32] $i) {
            [uint32] $level = $this.GetItem($i).Level()
            for (; ($i -gt 0) -and ($this.GetItem($i - 1).Level() -ge $level); --$i) {}
            return $i
        }

        function GetLastAtLevel([uint32] $i) {
            [uint32] $level = $this.GetItem($i).Level()
            for (; ($i -lt $this.ItemCount() - 1) -and ($this.GetItem($i + 1).Level() -ge $level); ++$i) {}
            return $i
        }

        [uint32] $first = GetFirstAtLevel $startIndex
        [uint32] $last = GetLastAtLevel $startIndex

        for ([uint32] $i = 1; $i -le $levelDistance; ++$i) {
            if (($first -gt 0) -and ($this.GetItem($first - 1).Level() -eq $this.GetItem($startIndex).Level() - $i)) {
                # Assert ($this.Items[$first - 1].Level() -eq $this.Items[$first].Level() - 1)
                $first = GetFirstAtLevel ($first - 1)
            }

            if (($last -lt $this.ItemCount() - 1) -and ($this.GetItem($last + 1).Level() -eq $this.GetItem($startIndex).Level() - $i)) {
                # Assert ($this.Items[$last + 1].Level() -lt $this.Items[$last].Level())
                $last = GetLastAtLevel ($last + 1)
            }
        }

        return $first, $last
    }

    [void] Expand() {
        [Log]::BeginSection("TV.Expand: $($this.TraceInfo())")

        try {

            # Can the item be expanded?
            if (!$this.SelectedItem().IsContainer()) {
                [console]::Beep(300, 100)
                return
            }

            # Is the item already expanded?
            if ($this.SelectedItem().IsExpanded()) {
                [console]::Beep(300, 100)
                return
            }

            $children = $this.SelectedItem().Children()

            if ($children.Count -eq 0) {
                [console]::Beep(300, 100)
                return
            }

            if ([Log]::Listeners.Count -gt 0) { $this.TraceItems() }

            if (($this.SelectedItem().Level() - $this.topLevelInView) -eq ($this.MaxLevelCount - 1)) {
                # With the level we're about to expand, we would exceed the maximum level count.
                # Prune ancestors and unindent remaining items.

                # 00: I0-00
                # 01: I1-00 <-- first
                # 02: I1-01
                # 03: I2-00
                # 04: I2-01
                # 05: I3-00 *
                # 06: ... (items about to get expanded)
                # 07: I3-01
                # 08: I2-02
                # 09: I1-02 <-- last
                # 10: I0-1

                $first, $last = $this.GetAncestralSiblingRange($this.SelectedIndex(), $this.MaxLevelCount - <# one for gaps vs. items, another one for the add'l level inserted above #> 2)
                #[Log]::Trace("TV.Expand.MaxLevelOverflow2: first=$first, last=$last")

                # Prune every item outside of [$first, $last]
                for ($i = $this.ItemCount() - 1; $i -gt $last; --$i) { $this.RemoveItem($i) }
                for ($i = $first; $i -gt 0; --$i) { $this.RemoveItem($i - 1) }

                ++$this.topLevelInView
                $this.FirstRowInView = [Math]::Max(0, $this._selectedIndex - $this.ClientHeight())
                #[Log]::Trace("TV.Expand.MaxLevelOverflow3: SIx=$($this._selectedIndex); FRIV=$($this.FirstRowInView); TLIV=$($this.topLevelInView)")

                # As indentation has changed due to the pruning above, we need to re-render the
                # text buffer.

                for ($i = 0; $i -lt $this.ItemCount(); ++$i) {
                    # We deal with the selected item below.
                    if ($i -ne $this.SelectedIndex()) {
                        $this.GetLine($i).Text = $this.GetItemLabel($this.GetItem($i));
                    }
                }
            }

            # [Log]::Trace("TV.Expand.SelectedItemExpand, Name='$($this.SelectedItem().Name())', IsExpanded=$($this.SelectedItem().IsExpanded()), IsContainer=$($this.SelectedItem().IsContainer())")
            $this.SelectedItem().Expand() # only changes the state of the item itself, does not add or remove children

            # re-render text for expansion state icon
            $this.GetLine($this.SelectedIndex()).Text = $this.GetItemLabel($this.GetItem($this.SelectedIndex()));

            [uint32] $ii = $this.SelectedIndex()
            foreach ($child in $children) {
                $this.InsertItem(++$ii, $child)
            }

            $this.SelectItem($this.SelectedIndex() + 1)

            # ensure selected item is in view
            #
            # 00:
            # 01: +-
            # 02: | FirstRowInView
            # 03: |
            # 04: |
            # 05: +- SelectedIndex
            if (($this.SelectedIndex() - $this.FirstRowInView) -ge $this.ClientHeight()) {
                $this.FirstRowInView = $this.SelectedIndex() - $this.ClientHeight() + 1
            }

            $this.DrawClientArea()

        } finally {
            [Log]::EndSection("TV.Expand: $($this.TraceInfo())")
        }
    }

    [void] Collapse() {
        [Log]::BeginSection("TV.Collapse: $($this.TraceInfo())")
        try {
            if ([Log]::Listeners.Count -gt 0) { $this.TraceItems() }

            # Going up deepens the displayed tree. Is there a way to return it to the depth we started
            # at? Perhaps we can have a "sliding window" of the last n ancestral levels?

            # Q: should we right-shift a) whenever we drop beneath MaxLevelCount or b) when we're closing
            # level 0?
            # As we could have left folders expanded by going out of them via CursorUp/-Down, right-shifting
            # could create more than MaxLevelCount levels.

            if ($this.SelectedItem().Level() -gt $this.topLevelInView) {
                [Log]::Trace("TV.Collapse.ParentPresent")

                [uint32] $first, $last = $this.GetAncestralSiblingRange($this.SelectedIndex(), 0)

                # remove collapsed items (iterating backwards for index consistency)
                for ([uint32] $i = $last; $i -ge $first; --$i) { $this.RemoveItem($i) }

                $this.SelectItem($first - 1)

                $this.SelectedItem().Collapse() # only changes expand/collapse state, does not manipulate children

                # re-render text for expansion-state indicating icon
                $this.GetLine($this.SelectedIndex()).Text = $this.GetItemLabel($this.SelectedItem())

                $this.FirstRowInView = [Math]::Min($this.FirstRowInView, $this.SelectedIndex())

            } else {
                [Log]::Trace("TV.Collapse.NeedToFetchParent: Name='$($this.SelectedItem().Name())'")
                $parent = $this.SelectedItem().Parent()

                if ($parent -eq $null) { return }

                --$this.topLevelInView # equivalent to assigning '$parent.Level()'

                # As we're expanding the tree view toward the root, prune the nodes that would exceed the
                # maximum view depth.
                for ([int] $i = $this.ItemCount() - 1; $i -ge 0; --$i) {
                    # [Log]::Trace("TV.Collapse.PruneReindentLoop: Name=$($this.GetItem($i).Name()), Level=$($this.GetItem($i).Level()), TLIV=$($this.topLevelInView)")
                    if (($this.GetItem($i).Level() - $this.topLevelInView) -ge $this.MaxLevelCount) {
                        # prune item as it would exceed maximum view depth
                        $this.RemoveItem($i)
                    } else {
                        # re-render text to capture increased indentation
                        # [Log]::Trace("TV.Collapse.Reindent: Name=$($this.GetItem($i).Name())")
                        $this.GetLine($i).Text = $this.GetItemLabel($this.GetItem($i))
                    }
                }

                if (!$parent.IsExpanded()) { $parent.Expand() }
                $this.InsertItem(0, $parent)
                $this.SelectItem(0)
                $this.FirstRowInView = 0

                # [Log]::Trace("TV.Collapse.FillInParentSiblings: TLIV=$($this.topLevelInView)")

                $grandParent = $parent.Parent()
                if ($grandParent -ne $null) {
                    # insert the parent's siblings
                    [uint32] $insertPosition = 0

                    foreach ($grandParentChild in $grandParent.Children()) {

                        #TODO: Name comparison might not be sufficient (it assumes unique names per level).
                        # Perhaps we need an 'Equals' method on tree view items.

                        if ($grandParentChild.Name() -eq $parent.Name()) {
                            # item has already been inserted per the line above
                            $this.SelectItem($insertPosition)

                            # continue inserting grand parent children after the current items
                            $insertPosition = $this.ItemCount()
                        } else {
                            $this.InsertItem($insertPosition++, $grandParentChild)
                        }
                    }

                    $this.FirstRowInView = [Math]::Max(0, $this.SelectedIndex() - $this.ClientHeight() + 1)
                }
            }

            $this.DrawClientArea()

        } finally {
            [Log]::EndSection("TV.Collapse: $($this.TraceInfo())")
        }
    }

    hidden [uint32] $topLevelInView = 0
}