browsh/interfacer/src/browsh/frame_builder.go

332 lines
9.5 KiB
Go

package browsh
import (
"encoding/json"
"fmt"
"log/slog"
"unicode"
"github.com/gdamore/tcell"
)
// A frame is a single snapshot of the DOM. The TTY is merely a window onto a
// region of this frame.
type frame struct {
// Dimensions of the frame's real data. Can be less than the DOM dimensions because
// we cannot sync frames of unlimited size from the browser.
subWidth int
subHeight int
// If the frame is smaller than the DOM, then this is the frame's position
// within the overall DOM.
subLeft int
subTop int
// The total DOM dimensions. These are measured in the same units of the frame
totalWidth int
totalHeight int
// The current position of the scroll in the TTY. Should be synced with the real
// browser.
xScroll int
yScroll int
// Usually we want to just overlay new data. But if the DOM changes then all bets are off
// and we need to start from scratch again. It's just too unpredictable how data for a DOM
// of a different size and shape will interact with data from another DOM.
isDOMSizeChanged bool
// Raw data used to build a single, usable frame
pixels map[int][2]tcell.Color
text map[int][]rune
textColours map[int]tcell.Color
// The actual built frame, can be used to render cells to the TTY
cells *threadSafeCellsMap
// Input boxes, like for entering passwords, sending emails etc
inputBoxes map[string]*inputBox
}
type jsonFrameBase struct {
TabID int `json:"id"`
SubWidth int `json:"sub_width"`
SubHeight int `json:"sub_height"`
SubLeft int `json:"sub_left"`
SubTop int `json:"sub_top"`
TotalWidth int `json:"total_width"`
TotalHeight int `json:"total_height"`
}
type incomingFrameText struct {
Meta jsonFrameBase `json:"meta"`
Text []string `json:"text"`
Colours []int32 `json:"colours"`
InputBoxes map[string]inputBox `json:"input_boxes"`
}
// TODO: Can these be sent as binary blobs?
type incomingFramePixels struct {
Meta jsonFrameBase `json:"meta"`
Colours []int32 `json:"colours"`
}
func (f *frame) domRowCount() int {
return f.totalHeight / 2
}
func (f *frame) subRowCount() int {
return f.subHeight / 2
}
func parseJSONFrameText(jsonString string) {
var incoming incomingFrameText
jsonBytes := []byte(jsonString)
if err := json.Unmarshal(jsonBytes, &incoming); err != nil {
Shutdown(err)
}
if !isTabPresent(incoming.Meta.TabID) {
slog.Info(
fmt.Sprintf("Not building frame for non-existent tab ID: %d", incoming.Meta.TabID),
)
return
}
Tabs[incoming.Meta.TabID].frame.buildFrameText(incoming)
}
func (f *frame) buildFrameText(incoming incomingFrameText) {
f.setup(incoming.Meta)
if !f.isIncomingFrameTextValid(incoming) {
return
}
f.updateInputBoxes(incoming)
f.populateFrameText(incoming)
}
func parseJSONFramePixels(jsonString string) {
var incoming incomingFramePixels
jsonBytes := []byte(jsonString)
if err := json.Unmarshal(jsonBytes, &incoming); err != nil {
Shutdown(err)
}
if !isTabPresent(incoming.Meta.TabID) {
slog.Info(
fmt.Sprintf("Not building frame for non-existent tab ID: %d", incoming.Meta.TabID),
)
return
}
if len(Tabs[incoming.Meta.TabID].frame.text) == 0 {
return
}
Tabs[incoming.Meta.TabID].frame.buildFramePixels(incoming)
}
func (f *frame) buildFramePixels(incoming incomingFramePixels) {
f.setup(incoming.Meta)
if !f.isIncomingFramePixelsValid(incoming) {
return
}
f.populateFramePixels(incoming)
}
func (f *frame) setup(meta jsonFrameBase) {
f.isDOMSizeChanged = meta.TotalWidth != f.totalWidth || meta.TotalHeight != f.totalHeight
if f.isDOMSizeChanged || f.cells == nil {
f.resetCells()
}
if f.inputBoxes == nil {
f.inputBoxes = make(map[string]*inputBox)
}
f.subWidth = meta.SubWidth
f.subHeight = meta.SubHeight
f.totalWidth = meta.TotalWidth
f.totalHeight = meta.TotalHeight
f.subLeft = meta.SubLeft
f.subTop = meta.SubTop
}
func (f *frame) resetCells() {
f.cells = newCellsMap()
}
func (f *frame) isIncomingFrameTextValid(incoming incomingFrameText) bool {
if len(incoming.Text) == 0 {
slog.Info("Not parsing zero-size text frame")
return false
}
return true
}
// TODO: There must be a more idiomatic way of doing this?
func (f *frame) updateInputBoxes(incoming incomingFrameText) {
for _, existingInputBox := range f.inputBoxes {
if _, ok := incoming.InputBoxes[existingInputBox.ID]; !ok {
// TODO: Does this also delete the memory pointed to by the reference?
delete(f.inputBoxes, existingInputBox.ID)
}
}
for _, incomingInputBox := range incoming.InputBoxes {
if _, ok := f.inputBoxes[incomingInputBox.ID]; !ok {
f.inputBoxes[incomingInputBox.ID] = newInputBox(incomingInputBox.ID)
}
inputBox := f.inputBoxes[incomingInputBox.ID]
inputBox.X = incomingInputBox.X
// TODO: Why do we have to add the 1 to the y coord??
inputBox.Y = (incomingInputBox.Y + 1) / 2
inputBox.Width = incomingInputBox.Width
inputBox.Height = incomingInputBox.Height / 2
inputBox.FgColour = incomingInputBox.FgColour
inputBox.TagName = incomingInputBox.TagName
inputBox.Type = incomingInputBox.Type
}
}
func (f *frame) populateFrameText(incoming incomingFrameText) {
var cellIndex, frameIndex, colourIndex int
if f.isDOMSizeChanged || f.text == nil {
f.text = make(map[int][]rune, (f.domRowCount())*f.totalWidth)
f.textColours = make(map[int]tcell.Color, (f.domRowCount())*f.totalWidth)
}
for y := 0; y < f.subRowCount(); y++ {
for x := 0; x < f.subWidth; x++ {
cellIndex = f.getCellIndexFromSubCoords(x, y*2)
frameIndex = (y * f.subWidth) + x
colourIndex = frameIndex * 3
f.textColours[cellIndex] = tcell.NewRGBColor(
incoming.Colours[colourIndex+0],
incoming.Colours[colourIndex+1],
incoming.Colours[colourIndex+2],
)
f.text[cellIndex] = []rune(incoming.Text[frameIndex])
f.buildCell(f.subLeft+x, (f.subTop/2)+y)
}
}
}
func (f *frame) populateFramePixels(incoming incomingFramePixels) {
var cellIndex, frameIndexFg, frameIndexBg, pixelIndexFg, pixelIndexBg int
if f.isDOMSizeChanged || f.pixels == nil {
f.pixels = make(map[int][2]tcell.Color, f.totalHeight*f.totalWidth)
}
data := incoming.Colours
for y := 0; y < f.subHeight; y += 2 {
for x := 0; x < f.subWidth; x++ {
cellIndex = f.getCellIndexFromSubCoords(x, y)
frameIndexBg = (y * f.subWidth) + x
frameIndexFg = ((y + 1) * f.subWidth) + x
pixelIndexBg = frameIndexBg * 3
pixelIndexFg = frameIndexFg * 3
pixels := [2]tcell.Color{
tcell.NewRGBColor(
data[pixelIndexBg+0],
data[pixelIndexBg+1],
data[pixelIndexBg+2],
),
tcell.NewRGBColor(
data[pixelIndexFg+0],
data[pixelIndexFg+1],
data[pixelIndexFg+2],
),
}
f.pixels[cellIndex] = pixels
f.buildCell(f.subLeft+x, (f.subTop+y)/2)
}
}
}
func (f *frame) isIncomingFramePixelsValid(incoming incomingFramePixels) bool {
if len(incoming.Colours) == 0 {
slog.Info("Not parsing zero-size text frame")
return false
}
return true
}
// This is where we implement the UTF8 half-block trick.
// This a half-block: "▄", notice how it takes up precisely half a text cell. This
// means that we can get 2 pixel colours from it, the top pixel comes from setting
// the background colour and the bottom pixel comes from setting the foreground
// colour, namely the colour of the text.
func (f *frame) buildCell(x int, y int) {
index := (y * f.totalWidth) + x
character, fgColour := f.getCharacterAt(index)
pixelFg, bgColour := f.getPixelColoursAt(index)
if isCharacterTransparent(character) {
character = []rune("▄")
fgColour = pixelFg
}
f.addCell(index, fgColour, bgColour, character)
}
func (f *frame) getCharacterAt(index int) ([]rune, tcell.Color) {
var colour tcell.Color
var character []rune
if result, ok := f.text[index]; ok {
character = result
colour = f.textColours[index]
} else {
character = []rune(" ")
colour = tcell.ColorBlack
}
return character, colour
}
func (f *frame) getPixelColoursAt(index int) (tcell.Color, tcell.Color) {
var fgColour, bgColour tcell.Color
if result, ok := f.pixels[index]; ok {
bgColour = result[0]
fgColour = result[1]
} else {
x := index % f.subWidth
fgColour, bgColour = getHatchedCellColours(x)
}
return fgColour, bgColour
}
func isCharacterTransparent(character []rune) bool {
return string(character) == "" || unicode.IsSpace(character[0])
}
func (f *frame) addCell(index int, fgColour, bgColour tcell.Color, character []rune) {
newCell := cell{
fgColour: fgColour,
bgColour: bgColour,
character: character,
}
f.cells.store(index, newCell)
}
// When iterating over a sub frame we still need to place the resulting data into the
// overall frame grid. So here we're essentially mapping relative coordinates to
// absolute ones. Also note that the y coord is converted from the frame pixels value
// to the TTY row value.
func (f *frame) getCellIndexFromSubCoords(x, y int) int {
yInAbsoluteFrameTTY := (y + f.subTop) / 2
return (yInAbsoluteFrameTTY * f.totalWidth) + (x + f.subLeft)
}
func (f *frame) limitScroll(height int) {
maxYScroll := f.domRowCount() - height
if f.yScroll > maxYScroll {
f.yScroll = maxYScroll
}
if f.yScroll < 0 {
f.yScroll = 0
}
}
func (f *frame) maybeFocusInputBox(x, y int) {
activeInputBox = nil
for _, inputBox := range f.inputBoxes {
inputBox.isActive = false
top := inputBox.Y
bottom := inputBox.Y + inputBox.Height
left := inputBox.X
right := inputBox.X + inputBox.Width
if x >= left && x < right && y >= top && y < bottom {
urlBarFocus(false)
inputBox.isActive = true
activeInputBox = inputBox
}
}
}
func (f *frame) overlayInputBoxContent() {
for _, inputBox := range f.inputBoxes {
inputBox.setCells()
}
}