import Bliku.Tui.Primitives
import Bliku.Tui.Model
import Bliku.Tui.Window
import Bliku.Tui.Overlay
import Bliku.Tui.RenderInput
import Bliku.Tui.Terminal
import Bliku.Tui.Syntax
import Bliku.Decoration
import Bliku.Widget.TextView
import Bliku.Widget.Chrome
namespace Bliku.Tui
open Bliku.Tui.Terminal
private def getBufferLine (buf : BufferState) (row : Nat) : String :=
buf.lines[row]?.getD ""
private def charDisplayWidth (ch : Char) : Nat :=
let n := ch.toNat
if
(0x1100 <= n && n <= 0x115F) ||
(0x2329 <= n && n <= 0x232A) ||
(0x2E80 <= n && n <= 0xA4CF) ||
(0xAC00 <= n && n <= 0xD7A3) ||
(0xF900 <= n && n <= 0xFAFF) ||
(0xFE10 <= n && n <= 0xFE19) ||
(0xFE30 <= n && n <= 0xFE6F) ||
(0xFF00 <= n && n <= 0xFF60) ||
(0xFFE0 <= n && n <= 0xFFE6)
then 2 else 1
private def lineDisplayLength (line : String) : Nat :=
line.toList.foldl (fun acc ch => acc + charDisplayWidth ch) 0
private def takeDisplayWidth (s : String) (width : Nat) : String := Id.run do
let mut out := ""
let mut used := 0
for ch in s.toList do
let chWidth := charDisplayWidth ch
if used + chWidth <= width then
out := out.push ch
used := used + chWidth
out
private def padPlainLine (s : String) (width : Nat) : String :=
let clipped := takeDisplayWidth s width
let clippedWidth := lineDisplayLength clipped
let pad := if clippedWidth < width then "".pushn ' ' (width - clippedWidth) else ""
clipped ++ pad
def renderEmptyLineMarkerInRect (config : UiConfig) (width : Nat) : String :=
padPlainLine config.emptyLineMarker width
private def renderedWindowLineWidth
(line : String) (scrollCol availableWidth : Nat) (windowCursor : Option Cursor) : Nat :=
let lineLen := lineDisplayLength line
let visibleLen := if lineLen > scrollCol then min availableWidth (lineLen - scrollCol) else 0
match windowCursor with
| some cur =>
if cur.col >= scrollCol then
let visCol := cur.col - scrollCol
if visibleLen <= visCol && visCol < availableWidth then
visCol + 1
else
visibleLen
else
visibleLen
| none => visibleLen
private def getExternalSyntaxLine (buf : BufferState) (lineIdx : Nat) : Array Syntax.Span :=
match buf.syntaxState.lineSpans.find? (fun (row, _) => row == lineIdx) with
| some (_, spans) => spans
| none => #[]
private def syntaxPaletteFor (m : Model) (buf : BufferState) : Syntax.Palette :=
Syntax.Palette.merge m.config.syntaxPalette buf.syntaxState.paletteOverrides
private def syntaxSpansForLine (buf : BufferState) (lineIdx : Nat) (line : String) : Array Syntax.Span :=
let builtin := Syntax.highlightLine buf.filename line
let external := getExternalSyntaxLine buf lineIdx
external ++ builtin
private def byteDecorationsForLine (m : Model) (buf : BufferState) (lineIdx : Nat) (line : String) : Array ByteDecoration :=
let palette := syntaxPaletteFor m buf
(syntaxSpansForLine buf lineIdx line).foldl (fun acc span =>
match Syntax.Palette.faceFor palette span.kind with
| some face =>
acc.push {
row := lineIdx
startByte := span.startByte
endByte := span.endByte
priority := 10
style := face.toAnsi
}
| none => acc) #[]
private def normalizeCursorRange (a b : Cursor) : Cursor × Cursor :=
if a.row < b.row || (a.row == b.row && a.col <= b.col) then (a, b) else (b, a)
private def isInSelection (selection : SelectionState) (row col : Nat) : Bool :=
if selection.block then
let minRow := min selection.anchor.row selection.cursor.row
let maxRow := max selection.anchor.row selection.cursor.row
let minCol := min selection.anchor.col selection.cursor.col
let maxCol := max selection.anchor.col selection.cursor.col
row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
else
let (p1, p2) := normalizeCursorRange selection.anchor selection.cursor
if row < p1.row || row > p2.row then false
else if row > p1.row && row < p2.row then true
else if p1.row == p2.row then col >= p1.col && col <= p2.col
else if row == p1.row then col >= p1.col
else if row == p2.row then col <= p2.col
else false
private def selectionDecorationsForLine
(config : UiConfig) (selection : Option SelectionState) (lineIdx scrollCol availableWidth : Nat) : Array CellDecoration := Id.run do
let some selection := selection | return #[]
let mut startCol : Option Nat := none
let mut endCol := scrollCol
for col in [scrollCol:scrollCol + availableWidth] do
if isInSelection selection lineIdx col then
if startCol.isNone then
startCol := some col
endCol := col + 1
match startCol with
| some start =>
#[{
row := lineIdx
startCol := start
endCol := endCol
priority := 100
style := config.visualSelectionStyle
}]
| none => #[]
private def cursorDecorationForLine
(m : Model) (windowId lineIdx : Nat) (windowCursor : Option Cursor) : Option CursorDecoration :=
match windowCursor with
| some cur =>
if windowId == m.workspace.activeWindowId && lineIdx == cur.row then
some {
row := lineIdx
col := cur.col
charStyle := m.config.cursorCharStyle
spaceStyle := m.config.cursorSpaceStyle
}
else
none
| none => none
def renderVisibleLine
(m : Model) (input : RenderInput) (buf : BufferState) (windowId lineIdx scrollCol availableWidth : Nat)
(windowCursor : Option Cursor) : String :=
let line := getBufferLine buf lineIdx
let props : Bliku.Widget.TextViewProps := {
lines := #[line]
scrollCol := scrollCol
byteDecorations := (byteDecorationsForLine m buf lineIdx line).map (fun deco => { deco with row := 0 })
cellDecorations := (selectionDecorationsForLine m.config input.selection lineIdx scrollCol availableWidth).map (fun deco =>
{ deco with row := 0 })
cursor := (cursorDecorationForLine m windowId lineIdx windowCursor).map (fun deco => { deco with row := 0 })
resetStyle := m.config.resetStyle
}
let styled := Bliku.Widget.renderVisibleLine props 0 availableWidth
Bliku.Widget.appendCursorOnBlankCell props 0 availableWidth styled
def renderVisibleLineInRect
(m : Model) (input : RenderInput) (buf : BufferState) (windowId lineIdx scrollCol availableWidth : Nat)
(windowCursor : Option Cursor) : String :=
let line := getBufferLine buf lineIdx
let rendered := renderVisibleLine m input buf windowId lineIdx scrollCol availableWidth windowCursor
let occupied := renderedWindowLineWidth line scrollCol availableWidth windowCursor
let pad := if occupied < availableWidth then "".pushn ' ' (availableWidth - occupied) else ""
rendered ++ pad
private def renderWindow (m : Model) (input : RenderInput) (windowId : Nat) (view : ViewState) (rect : Rect) : (Array String × Option (Nat × Nat)) := 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 buf := getBuffer m view.bufferId
let mut out : Array String := #[]
for i in [0:workH] do
let lineIdx := view.scrollRow + i
out := out.push (moveCursorStr (r + i) c)
let lnWidth := if m.config.showLineNumbers then 4 else 0
let availableWidth := if w > lnWidth then w - lnWidth else 0
if lineIdx < buf.lines.size then
let windowCursor := if windowId == m.workspace.activeWindowId then some view.cursor else none
if m.config.showLineNumbers then
out := out.push s!"{leftPad (toString (lineIdx + 1)) 3} "
out := out.push (renderVisibleLineInRect m input buf windowId lineIdx view.scrollCol availableWidth windowCursor)
else
out := out.push (renderEmptyLineMarkerInRect m.config w)
let statusRow := r + workH
out := out.push (moveCursorStr statusRow c)
out := out.push m.config.statusBarStyle
out := out.push (padPlainLine input.statusLine w)
out := out.push m.config.resetStyle
let cursorPos : Option (Nat × Nat) :=
if view.cursor.row < view.scrollRow then none
else
let visRow := view.cursor.row - view.scrollRow
if visRow < workH then
let colOffset := if m.config.showLineNumbers then 4 else 0
let visCol := if view.cursor.col >= view.scrollCol then view.cursor.col - view.scrollCol else 0
if visCol + colOffset < w then
some (r + visRow, c + visCol + colOffset)
else none
else none
(out, cursorPos)
private def renderLayout (m : Model) (input : RenderInput) (l : Layout) (r c h w : Nat) (skipFloating : Bool := true) : (Array String × Option (Nat × Nat)) := Id.run do
match l with
| .group _ body =>
return renderLayout m input body r c h w skipFloating
| .pane id view =>
if skipFloating && m.workspace.isFloatingWindow id then
let mut blank : Array String := #[]
for i in [0:h] do
blank := blank.push (moveCursorStr (r + i) c)
blank := blank.push ("".pushn ' ' w)
return (blank, none)
let (buf, cur) := renderWindow m input id view { row := r, col := c, height := h, width := w }
return (buf, if id == m.workspace.activeWindowId then cur else none)
| .hsplit left right ratio =>
let leftW := (Float.ofNat w * ratio).toUInt64.toNat
let (leftBuf, leftCur) := renderLayout m input left r c h leftW skipFloating
let mut sep : Array String := #[]
if w > leftW then
let sepCol := c + leftW
for i in [0:h] do
sep := sep.push (moveCursorStr (r + i) sepCol)
sep := sep.push m.config.vSplitStr
let (rightBuf, rightCur) := renderLayout m input right r (c + leftW + 1) h (if w > leftW then w - leftW - 1 else 0) skipFloating
return (leftBuf ++ sep ++ rightBuf, rightCur.orElse (fun _ => leftCur))
| .vsplit top bottom ratio =>
let topH := (Float.ofNat h * ratio).toUInt64.toNat
let (topBuf, topCur) := renderLayout m input top r c topH w skipFloating
let mut sep : Array String := #[]
if h > topH then
let sepRow := r + topH
sep := sep.push (moveCursorStr sepRow c)
for _ in [0:w] do
sep := sep.push m.config.hSplitStr
let (bottomBuf, bottomCur) := renderLayout m input bottom (r + topH + 1) c (if h > topH then h - topH - 1 else 0) w skipFloating
return (topBuf ++ sep ++ bottomBuf, bottomCur.orElse (fun _ => topCur))
private def renderFloatingClusters (m : Model) (input : RenderInput) : (Array String × Option (Nat × Nat)) := Id.run do
let ws := m.workspace
let mut out : Array String := #[]
let mut activeCur : Option (Nat × Nat) := none
let clusters := ws.floatingClusters
for i in [0:clusters.size] do
let cluster := clusters[i]!
match ws.layout.extractFloatingRoot cluster.root,
computeFloatingClusterBounds m.windowHeight m.windowWidth i cluster with
| some subtree, some (baseTop, baseLeft, h, w) =>
let toNatNonNeg (v : Int) := if v < 0 then 0 else Int.toNat v
let availableRows := if m.windowHeight > 1 then m.windowHeight - 1 else m.windowHeight
let maxTop := if availableRows > h then availableRows - h else 0
let maxLeft := if m.windowWidth > w then m.windowWidth - w else 0
let top := min maxTop (toNatNonNeg (Int.ofNat baseTop + cluster.rowOffset))
let left := min maxLeft (toNatNonNeg (Int.ofNat baseLeft + cluster.colOffset))
let isActive := subtree.getPaneIds.contains ws.activeWindowId
let chromeBox := Bliku.Widget.renderFloatingChromeBox
cluster.chrome w h
m.config.floatingChromeActiveStyle
m.config.floatingChromeInactiveStyle
m.config.resetStyle
isActive
if !chromeBox.lines.isEmpty then
for j in [0:chromeBox.lines.size] do
out := out.push (moveCursorStr (top + j) left)
out := out.push chromeBox.lines[j]!
let innerTop := top + chromeBox.insetTop
let innerLeft := left + chromeBox.insetLeft
let innerH := h - chromeBox.insetTop - chromeBox.insetBottom
let innerW := w - chromeBox.insetLeft - chromeBox.insetRight
if innerH > 0 && innerW > 0 then
let (buf, cur) := renderLayout m input subtree innerTop innerLeft innerH innerW false
out := out ++ buf
if isActive then
activeCur := cur
| _, _ => pure ()
(out, activeCur)
private def findOverlayCursorPos (rows cols : Nat) (input : RenderInput) : Option (Nat × Nat) :=
match input.overlay with
| none => none
| some ov =>
match computeFloatingOverlayLayout rows cols ov with
| none => none
| some layout =>
let lines := if ov.lines.isEmpty then #[""] else ov.lines
let maxRow := lines.size - 1
let rowIdx := min ov.cursorRow maxRow
let lineLen := (lines[rowIdx]!).length
let colIdx := min ov.cursorCol lineLen
let visRow := min rowIdx (layout.contentRows - 1)
let visCol := min colIdx layout.innerWidth
some (layout.top + 1 + layout.titleRows + visRow, layout.left + 2 + visCol)
/-- Render model to terminal and return updated model (size synchronized). -/
def renderWith (model : Model) (input : RenderInput) : IO Model := do
if !model.dirty then
return model
let (rows, cols) ← getWindowSize
let m := { model with windowHeight := rows, windowWidth := cols }
let mut buffer : Array String := #[]
buffer := buffer.push hideCursorStr
let layoutH := if rows > 0 then rows - 1 else 0
let baseLayout :=
m.workspace.floatingClusters.foldl (fun acc cluster =>
match acc with
| some l => l.removeFloatingRoot cluster.root
| none => none) (some m.workspace.layout)
if baseLayout.isNone then
buffer := buffer.push clearScreenStr
buffer := buffer.push homeCursorStr
let (layoutBuf, activeCur) :=
match baseLayout with
| some l => renderLayout m input l 0 0 layoutH cols true
| none => (#[], none)
buffer := buffer ++ layoutBuf
let (floatBuf, floatCur) := renderFloatingClusters m input
buffer := buffer ++ floatBuf
buffer := buffer.push (moveCursorStr (rows - 1) 0)
buffer := buffer.push (padPlainLine (renderStatusBar input) cols)
let overlayToRender := input.overlay.orElse (fun _ => messageOverlayForState input)
if let some ov := overlayToRender then
buffer := buffer ++ renderFloatingOverlay rows cols ov
if let some popup := input.completion then
buffer := buffer ++ renderCompletionPopup rows cols m.config popup activeCur
let overlayCur := findOverlayCursorPos rows cols input
buffer := buffer.push (
if input.overlay.isSome && input.commandLine.isNone then
match overlayCur with
| some (pr, pc) => moveCursorStr pr pc
| none => ""
else
match floatCur.orElse (fun _ => activeCur) with
| some (pr, pc) => moveCursorStr pr pc
| none => ""
)
if let some prompt := input.commandLine then
buffer := buffer.push (moveCursorStr (rows - 1) (Bliku.Widget.promptCursorCol { leader := prompt.leader, text := prompt.text, cursorCol := prompt.cursorCol }))
if input.hideTerminalCursor then
buffer := buffer.push hideCursorStr
else
buffer := buffer.push showCursorStr
IO.print (String.intercalate "" buffer.toList)
(← IO.getStdout).flush
return m
def render (model : Model) : IO Model :=
renderWith model {}
end Bliku.Tui