Public/View/New-ElmTextarea.ps1

function New-ElmTextarea {
    <#
    .SYNOPSIS
        Creates a multi-line text area view node.

    .DESCRIPTION
        Returns a Box (Vertical) view node representing a multi-line editable text
        area. The caller manages Lines, CursorRow, CursorCol, and ScrollOffset in
        the model. Key subscriptions forward character input, Backspace, Enter,
        and cursor movement to the update function.

        When focused, a cursor character is inserted into the rendered cursor row
        at CursorCol. ScrollOffset controls which lines are visible. Lines outside
        the [ScrollOffset, ScrollOffset + MaxVisible) window are not rendered.

        Rendered example (MaxVisible=4, focused, cursor on row 1 col 3):

            Line one
            Lin|e two
            Line three
            Line four

        Placeholder text is shown when Lines is empty or a single empty string
        and the widget is not focused.

    .PARAMETER Lines
        Array of strings; each element is one line of text. Required.

    .PARAMETER CursorRow
        Zero-based row index of the cursor. Clamped to [0, Lines.Count-1].
        Default: 0.

    .PARAMETER CursorCol
        Zero-based column index within the cursor row. Clamped to
        [0, Lines[CursorRow].Length]. Default: 0.

    .PARAMETER Focused
        When present, renders a cursor character at (CursorRow, CursorCol) and
        applies FocusedStyle instead of Style.

    .PARAMETER MaxVisible
        Number of lines to render at once. Default: 10.

    .PARAMETER ScrollOffset
        Zero-based index of the first visible line. Clamped so the window never
        extends past the end of Lines. Default: 0.

    .PARAMETER Placeholder
        Text shown as a single line when Lines is empty (or a single empty string)
        and -Focused is not set. Default: ''.

    .PARAMETER CursorChar
        Single character used to represent the cursor. Default: '|'.

    .PARAMETER Style
        Elm style applied to each line when the widget is not focused.

    .PARAMETER FocusedStyle
        Elm style applied to each line when the widget is focused. When omitted
        and -Focused is set, falls back to Style.

    .PARAMETER FocusedBoxStyle
        When provided and -Focused is set, applied as the Style of the outer
        Box node. Use to add a visible border around the focused area, e.g.:

            New-ElmStyle -Border 'Rounded' -Foreground 'BrightWhite'

        When null (default) or when unfocused, the outer Box has no Style.

    .OUTPUTS
        PSCustomObject - Box (Vertical) view node.

    .EXAMPLE
        New-ElmTextarea -Lines $model.Lines -CursorRow $model.Row -CursorCol $model.Col -Focused

    .EXAMPLE
        $focStyle = New-ElmStyle -Foreground 'BrightWhite'
        New-ElmTextarea -Lines $model.Body -CursorRow $model.Row -CursorCol $model.Col `
                        -Focused:$model.Editing `
                        -MaxVisible 20 -ScrollOffset $model.Scroll `
                        -Placeholder 'Start typing...' `
                        -FocusedStyle $focStyle

    .NOTES
        This widget is purely a view helper. Implement line splitting on Enter,
        line joining on Backspace-at-start, and cursor clamping in the Update
        function using key and char subscriptions.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$Lines,

        [Parameter()]
        [int]$CursorRow = 0,

        [Parameter()]
        [int]$CursorCol = 0,

        [Parameter()]
        [switch]$Focused,

        [Parameter()]
        [ValidateRange(1, [int]::MaxValue)]
        [int]$MaxVisible = 10,

        [Parameter()]
        [int]$ScrollOffset = 0,

        [Parameter()]
        [AllowEmptyString()]
        [string]$Placeholder = '',

        [Parameter()]
        [ValidateLength(1, 1)]
        [string]$CursorChar = '|',

        [Parameter()]
        [PSCustomObject]$Style = $null,

        [Parameter()]
        [PSCustomObject]$FocusedStyle = $null,

        [Parameter()]
        [PSCustomObject]$FocusedBoxStyle = $null
    )

    $activeStyle = if ($Focused.IsPresent -and $null -ne $FocusedStyle) {
        $FocusedStyle
    } else {
        $Style
    }

    # Normalise empty input to a single empty line.
    # @() ensures we always get an array: in PS, an if-else expression
    # returns pipeline output which unboxes a single-element [string[]] to a
    # scalar string; indexing a scalar string then returns [char] not [string].
    $lineList  = @(if ($Lines.Count -eq 0) { '' } else { $Lines })
    $lineCount = $lineList.Count

    # Show placeholder when empty and unfocused
    $isEmpty = $lineCount -eq 1 -and $lineList[0].Length -eq 0
    if ($isEmpty -and -not $Focused.IsPresent -and $Placeholder.Length -gt 0) {
        return [PSCustomObject]@{
            Type      = 'Box'
            Direction = 'Vertical'
            Children  = @([PSCustomObject]@{
                Type    = 'Text'
                Content = $Placeholder
                Style   = $Style
                Width   = 'Auto'
                Height  = 'Auto'
            })
            Style     = $activeStyle
            Width     = 'Auto'
            Height    = 'Auto'
        }
    }

    # Clamp cursor row and col
    $clampedRow = [math]::Max(0, [math]::Min($CursorRow, $lineCount - 1))
    $cursorLine = [string]$lineList[$clampedRow]
    $clampedCol = [math]::Max(0, [math]::Min($CursorCol, $cursorLine.Length))

    # Clamp scroll offset
    $maxOffset = [math]::Max(0, $lineCount - $MaxVisible)
    $offset    = [math]::Max(0, [math]::Min($ScrollOffset, $maxOffset))
    $endIdx    = [math]::Min($offset + $MaxVisible, $lineCount) - 1

    $children = [System.Collections.Generic.List[object]]::new()
    for ($i = $offset; $i -le $endIdx; $i++) {
        $line    = [string]$lineList[$i]
        $content = if ($Focused.IsPresent -and $i -eq $clampedRow) {
            $before = $line.Substring(0, $clampedCol)
            $after  = $line.Substring($clampedCol)
            $before + $CursorChar + $after
        } else {
            $line
        }
        $children.Add([PSCustomObject]@{
            Type    = 'Text'
            Content = $content
            Style   = $activeStyle
            Width   = 'Auto'
            Height  = 'Auto'
        })
    }

    $outerBoxStyle = if ($Focused.IsPresent -and $null -ne $FocusedBoxStyle) { $FocusedBoxStyle } else { $null }

    return [PSCustomObject]@{
        Type      = 'Box'
        Direction = 'Vertical'
        Children  = $children.ToArray()
        Style     = $outerBoxStyle
        Width     = 'Auto'
        Height    = 'Auto'
    }
}