text editor inspired vim and yi
import ViE.UI.Primitives
import ViE.UI.Search
import ViE.Terminal

namespace ViE.UI
open ViE

def renderWindow (state : EditorState) (windowId : Nat) (view : ViewState) (rect : Rect) : (Array String × EditorState) := Id.run do
  let r := rect.row
  let c := rect.col
  let h := rect.height
  let w := rect.width
  let workH := if h > 0 then h - 1 else 0
  let buf0 := getWorkspaceBuffer state view.bufferId
  let mut currentSt := state
  let mut winBuf : Array String := #[]

  let prefetchMargin := 20
  let totalLines := FileBuffer.lineCount buf0
  let startRow := if view.scrollRow.val > prefetchMargin then view.scrollRow.val - prefetchMargin else 0
  let endRow := min totalLines (view.scrollRow.val + workH + prefetchMargin)
  currentSt := prefetchSearchLineMatches currentSt buf0 startRow endRow

  for i in [0:workH] do
    let lineIdx : Row := ⟨view.scrollRow.val + i⟩
    let screenRow := r + i
    winBuf := winBuf.push (Terminal.moveCursorStr screenRow c)

    let buf := getWorkspaceBuffer currentSt view.bufferId
    if lineIdx.val < FileBuffer.lineCount buf then
      let lnWidth := if state.config.showLineNumbers then 4 else 0
      let availableWidth := if w > lnWidth then w - lnWidth else 0

      let cachedLine := buf.cache.find lineIdx
      let cachedRaw := buf.cache.findRaw lineIdx
      let cachedIdx := buf.cache.findIndex lineIdx
      let lineStr := cachedRaw.getD (getLineFromBuffer buf lineIdx |>.getD "")
      let lineIndex := cachedIdx.getD (ViE.Unicode.buildDisplayByteIndexWithTabStop lineStr state.config.tabStop)
      let (displayLine, nextSt) :=
        match cachedLine with
        | some (s, cachedScrollCol, cachedWidth) =>
            if cachedScrollCol == view.scrollCol.val && cachedWidth == availableWidth then
              (s, currentSt)
            else
              match getLineFromBuffer buf lineIdx with
              | some lineStr =>
                  let sub := ViE.Unicode.dropByDisplayWidthWithTabStop lineStr.toRawSubstring state.config.tabStop view.scrollCol.val
                  let dl := ViE.Unicode.takeByDisplayWidthWithTabStop sub state.config.tabStop availableWidth
                  let cache := match cachedRaw, cachedIdx with
                    | some raw, some _ =>
                        if raw == lineStr then
                          buf.cache
                        else
                          (buf.cache.updateIndex lineIdx (ViE.Unicode.buildDisplayByteIndexWithTabStop lineStr state.config.tabStop))
                    | _, _ =>
                        (buf.cache.updateIndex lineIdx (ViE.Unicode.buildDisplayByteIndexWithTabStop lineStr state.config.tabStop))
                  let cache := (cache.update lineIdx dl view.scrollCol.val availableWidth).updateRaw lineIdx lineStr
                  let updatedBuf := { buf with cache := cache }
                  let s' := currentSt.updateCurrentWorkspace fun ws =>
                    { ws with buffers := ws.buffers.map (fun (b : FileBuffer) => if b.id == buf.id then updatedBuf else b) }
                  (dl, s')
              | none => ("", currentSt)
        | none =>
            if let some lineStr := getLineFromBuffer buf lineIdx then
              let sub := ViE.Unicode.dropByDisplayWidthWithTabStop lineStr.toRawSubstring state.config.tabStop view.scrollCol.val
              let dl := ViE.Unicode.takeByDisplayWidthWithTabStop sub state.config.tabStop availableWidth
              let cache := match cachedRaw, cachedIdx with
                | some raw, some _ =>
                    if raw == lineStr then
                      buf.cache
                    else
                      (buf.cache.updateIndex lineIdx (ViE.Unicode.buildDisplayByteIndexWithTabStop lineStr state.config.tabStop))
                | _, _ =>
                    (buf.cache.updateIndex lineIdx (ViE.Unicode.buildDisplayByteIndexWithTabStop lineStr state.config.tabStop))
              let cache := (cache.update lineIdx dl view.scrollCol.val availableWidth).updateRaw lineIdx lineStr
              let updatedBuf := { buf with cache := cache }
              let s' := currentSt.updateCurrentWorkspace fun ws =>
                { ws with buffers := ws.buffers.map (fun (b : FileBuffer) => if b.id == buf.id then updatedBuf else b) }
              (dl, s')
            else
              ("", currentSt)
      currentSt := nextSt
      if state.config.showLineNumbers then
        winBuf := winBuf.push s!"{leftPad (toString (lineIdx.val + 1)) 3} "

      let isVisual := state.mode == Mode.visual || state.mode == Mode.visualBlock
      let selRange := if isVisual then state.selectionStart else none
      let (searchMatches, searchSt) := getLineSearchMatches currentSt buf.id lineIdx lineStr
      currentSt := searchSt

      let needsStyled := selRange.isSome || !searchMatches.isEmpty
      if !needsStyled then
        winBuf := winBuf.push displayLine
      else
        let mut styleActive := false
        let chars := displayLine.toList
        let mut dispIdx := 0
        let cursorByte :=
          if lineIdx == view.cursor.row then
            some (ViE.Unicode.displayColToByteOffsetFromIndex lineIndex view.cursor.col.val)
          else
            none
        let activeMatch := activeMatchRange searchMatches cursorByte
        for ch in chars do
          let chW := Unicode.charWidth ch
          let colVal := view.scrollCol.val + dispIdx
          let byteStart := ViE.Unicode.displayColToByteOffsetFromIndex lineIndex colVal
          let byteEnd := ViE.Unicode.displayColToByteOffsetFromIndex lineIndex (colVal + chW)
          let isSelected :=
            state.isInSelection lineIdx ⟨colVal⟩ ||
            (chW > 1 && state.isInSelection lineIdx ⟨colVal + 1⟩)
          let isMatched :=
            searchMatches.any (fun m => overlapsByteRange m byteStart byteEnd)
          let isActiveMatched :=
            match activeMatch with
            | some m => overlapsByteRange m byteStart byteEnd
            | none => false

          if isSelected then
            if !styleActive then
              winBuf := winBuf.push "\x1b[7m"
              styleActive := true
          else if isMatched then
            if !styleActive then
              if isActiveMatched then
                winBuf := winBuf.push state.config.searchHighlightCursorStyle
              else
                winBuf := winBuf.push state.config.searchHighlightStyle
              styleActive := true
          else if styleActive then
            winBuf := winBuf.push "\x1b[0m"
            styleActive := false

          winBuf := winBuf.push ch.toString
          dispIdx := dispIdx + chW

        if styleActive then
          winBuf := winBuf.push "\x1b[0m"
    else
      winBuf := winBuf.push state.config.emptyLineMarker

    winBuf := winBuf.push Terminal.clearLineStr

  let statusRow := r + workH
  let statusBuf := getWorkspaceBuffer currentSt view.bufferId
  winBuf := winBuf.push (Terminal.moveCursorStr statusRow c)
  let fileName := statusBuf.filename.getD "[No Name]"
  let modeStr := if windowId == currentSt.getCurrentWorkspace.activeWindowId then s!"-- {state.mode} --" else "--"
  let eolMark := if statusBuf.missingEol then " [noeol]" else ""
  let statusStr := s!"{modeStr} {fileName}{eolMark} [W:{windowId} B:{view.bufferId}] [{state.getCurrentWorkgroup.name}] {state.getCurrentWorkspace.name}"
  winBuf := winBuf.push state.config.statusBarStyle
  winBuf := winBuf.push statusStr.trimAscii.toString
  winBuf := winBuf.push Terminal.clearLineStr
  winBuf := winBuf.push state.config.resetStyle
  (winBuf, currentSt)

def renderFloatingWindow
    (st : EditorState)
    (view : ViewState)
    (rect : Rect)
    (borderFlags : FloatingWindowBorderFlags)
    : (Array String × Option (Nat × Nat)) := Id.run do
  let top := rect.row
  let left := rect.col
  let h := rect.height
  let w := rect.width
  let topBorderH := if borderFlags.top then 1 else 0
  let rightBorderW := if borderFlags.right then 1 else 0
  let bottomBorderH := if borderFlags.bottom then 1 else 0
  let leftBorderW := if borderFlags.left then 1 else 0
  let topBottomBorderH := topBorderH + bottomBorderH
  let sideBorderW := leftBorderW + rightBorderW
  if h < topBottomBorderH + 1 || w < sideBorderW + 1 then
    return (#[], none)
  let buf := getWorkspaceBuffer st view.bufferId
  let innerW := if w > sideBorderW then w - sideBorderW else 1
  let innerH := if h > topBottomBorderH then h - topBottomBorderH else 1
  let lnWidth := if st.config.showLineNumbers then 4 else 0
  let textW := if innerW > lnWidth then innerW - lnWidth else 1
  let repeatToken (token : String) (n : Nat) : String :=
    String.intercalate "" (List.replicate n token)
  let hBorder := if st.config.hSplitStr.isEmpty then "-" else st.config.hSplitStr
  let vBorder := if st.config.vSplitStr.isEmpty then "|" else st.config.vSplitStr
  let leftBorder := if borderFlags.left then vBorder else ""
  let rightBorder := if borderFlags.right then vBorder else ""
  let border := repeatToken hBorder w
  let mut out : Array String := #[]
  out := out.push st.config.resetStyle
  if borderFlags.top then
    out := out.push (Terminal.moveCursorStr top left)
    out := out.push border
  let isVisual := st.mode == Mode.visual || st.mode == Mode.visualBlock
  let hasSelection := isVisual && st.selectionStart.isSome
  for i in [0:innerH] do
    let lineIdx : Row := ⟨view.scrollRow.val + i⟩
    let raw := if lineIdx.val < FileBuffer.lineCount buf then getLineFromBuffer buf lineIdx |>.getD "" else ""
    let sub := ViE.Unicode.dropByDisplayWidthWithTabStop raw.toRawSubstring st.config.tabStop view.scrollCol.val
    let plainShown := ViE.Unicode.takeByDisplayWidthWithTabStop sub st.config.tabStop textW
    let shownW := Unicode.stringWidthWithTabStop plainShown st.config.tabStop
    let shown :=
      if hasSelection then
        Id.run do
          let mut styled : Array String := #[]
          let mut styleActive := false
          let mut dispCol := view.scrollCol.val
          for ch in plainShown.toList do
            let chW := Unicode.charWidth ch
            let selected :=
              st.isInSelection lineIdx ⟨dispCol⟩ ||
              (chW > 1 && st.isInSelection lineIdx ⟨dispCol + 1⟩)
            if selected then
              if !styleActive then
                styled := styled.push "\x1b[7m"
                styleActive := true
            else if styleActive then
              styled := styled.push st.config.resetStyle
              styleActive := false
            styled := styled.push ch.toString
            dispCol := dispCol + chW
          if styleActive then
            styled := styled.push st.config.resetStyle
          return String.intercalate "" styled.toList
      else
        plainShown
    let padW := if shownW < textW then textW - shownW else 0
    let pad := "".pushn ' ' padW
    let withNo :=
      if st.config.showLineNumbers then
        s!"{leftPad (toString (lineIdx.val + 1)) 3} {shown}{pad}"
      else
        s!"{shown}{pad}"
    out := out.push (Terminal.moveCursorStr (top + topBorderH + i) left)
    out := out.push s!"{leftBorder}{withNo}{rightBorder}"
  if borderFlags.bottom then
    out := out.push (Terminal.moveCursorStr (top + h - 1) left)
    out := out.push border
  out := out.push st.config.resetStyle

  let cursorPos : Option (Nat × Nat) :=
    if view.cursor.row.val < view.scrollRow.val then
      none
    else
      let visRow := view.cursor.row.val - view.scrollRow.val
      if visRow < innerH then
        let visCol := if view.cursor.col.val >= view.scrollCol.val then view.cursor.col.val - view.scrollCol.val else 0
        let colOff := if st.config.showLineNumbers then 4 else 0
        let c := min (innerW - 1) (colOff + visCol)
        some (top + topBorderH + visRow, left + leftBorderW + c)
      else
        none
  return (out, cursorPos)

end ViE.UI