Move UI building to CLI

* CLI is now prepared for supporting multiple tabs.
  * Refactored global vars into relevant files
  * Now using real types in JSON sent to CLI
  * Still doesn't fix integration tests
This commit is contained in:
Thomas Buckley-Houston 2018-04-28 12:07:39 +08:00
parent ba5ce3c58b
commit b605965e77
17 changed files with 400 additions and 356 deletions

View file

@ -6,7 +6,6 @@ import (
"fmt"
"strconv"
"io/ioutil"
"net"
"net/http"
"os"
"os/exec"
@ -19,7 +18,6 @@ import (
"github.com/gdamore/tcell"
"github.com/go-errors/errors"
"github.com/gorilla/websocket"
"github.com/shibukawa/configdir"
)
@ -33,51 +31,6 @@ var (
isDebug = flag.Bool("debug", false, "Log to ./debug.log")
startupURL = flag.String("startup-url", "https://google.com", "URL to launch at startup")
timeLimit = flag.Int("time-limit", 0, "Kill Browsh after the specified number of seconds")
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
stdinChannel = make(chan string)
marionette net.Conn
ffCommandCount = 0
isConnectedToWebExtension = false
screen tcell.Screen
uiHeight = 2
frame = Frame{}
State map[string]string
defaultFFPrefs = map[string]string{
"browser.startup.homepage": "'https://www.google.com'",
"startup.homepage_welcome_url": "'https://www.google.com'",
"startup.homepage_welcome_url.additional": "''",
"devtools.errorconsole.enabled": "true",
"devtools.chrome.enabled": "true",
// Send Browser Console (different from Devtools console) output to
// STDOUT.
"browser.dom.window.dump.enabled": "true",
// From:
// http://hg.mozilla.org/mozilla-central/file/1dd81c324ac7/build/automation.py.in//l388
// Make url-classifier updates so rare that they won"t affect tests.
"urlclassifier.updateinterval": "172800",
// Point the url-classifier to a nonexistent local URL for fast failures.
"browser.safebrowsing.provider.0.gethashURL": "'http://localhost/safebrowsing-dummy/gethash'",
"browser.safebrowsing.provider.0.keyURL": "'http://localhost/safebrowsing-dummy/newkey'",
"browser.safebrowsing.provider.0.updateURL": "'http://localhost/safebrowsing-dummy/update'",
// Disable self repair/SHIELD
"browser.selfsupport.url": "'https://localhost/selfrepair'",
// Disable Reader Mode UI tour
"browser.reader.detectedFirstArticle": "true",
// Set the policy firstURL to an empty string to prevent
// the privacy info page to be opened on every "web-ext run".
// (See #1114 for rationale)
"datareporting.policy.firstRunURL": "''",
}
// TestServerPort ... Port for the test server
TestServerPort = "4444"
)
func setupLogging() {
@ -94,7 +47,8 @@ func setupLogging() {
}
}
// Log ... general purpose logger
// Log for general purpose logging
// TODO: accept generic types
func Log(msg string) {
if !*isDebug {
return
@ -120,10 +74,12 @@ func initialise(isTesting bool) {
setupLogging()
}
// Shutdown ... Cleanly Shutdown browsh
// Shutdown tries its best to cleanly shutdown browsh and the associated browser
func Shutdown(err error) {
exitCode := 0
screen.Fini()
if screen != nil {
screen.Fini()
}
if err.Error() != "normal" {
exitCode = 1
println(err.Error())

View file

@ -3,12 +3,23 @@ package browsh
import (
"strings"
"fmt"
"encoding/json"
"net/http"
"github.com/gorilla/websocket"
)
var (
// TestServerPort is the port for the test web socket server
TestServerPort = "4444"
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
stdinChannel = make(chan string)
isConnectedToWebExtension = false
)
func sendMessageToWebExtension(message string) {
if (!isConnectedToWebExtension) {
Log("Webextension not connected. Message not sent: " + message)
@ -39,20 +50,14 @@ func handleWebextensionCommand(message []byte) {
command := parts[0]
switch command {
case "/frame_text":
frame.parseJSONFrameText(strings.Join(parts[1:], ","))
renderUI()
renderFrame()
parseJSONFrameText(strings.Join(parts[1:], ","))
renderCurrentTabWindow()
case "/frame_pixels":
frame.parseJSONFramePixels(strings.Join(parts[1:], ","))
parseJSONFramePixels(strings.Join(parts[1:], ","))
renderCurrentTabWindow()
case "/tab_state":
parseJSONTabState(strings.Join(parts[1:], ","))
renderUI()
renderFrame()
case "/state":
oldState := map[string]string{}
for k,v := range State{
oldState[k] = v
}
parseJSONState(strings.Join(parts[1:], ","))
handleStateChange(oldState)
case "/screenshot":
saveScreenshot(parts[1])
default:
@ -60,26 +65,6 @@ func handleWebextensionCommand(message []byte) {
}
}
func parseJSONState(jsonString string) {
jsonBytes := []byte(jsonString)
if err := json.Unmarshal(jsonBytes, &State); err != nil {
Shutdown(err)
}
}
func handleStateChange(oldState map[string]string) {
if (State["page_state"] != oldState["page_state"]) {
Log("State change: page_state=" + State["page_state"])
if (State["page_state"] == "page_init") {
yScroll = 0
}
}
if (State["frame_width"] != "" && State["frame_height"] != "") {
frame.width = toInt(State["frame_width"])
frame.height = toInt(State["frame_height"])
}
}
// When the socket reader attempts to read from a closed websocket it quickly and
// simply closes its associated Go routine. However the socket writer won't
// automatically notice until it actually needs to send something. So we force that
@ -113,11 +98,8 @@ func webSocketServer(w http.ResponseWriter, r *http.Request) {
if err != nil {
Shutdown(err)
}
isConnectedToWebExtension = true
go webSocketWriter(ws)
go webSocketReader(ws)
sendTtySize()
}

View file

@ -14,6 +14,41 @@ import (
"github.com/go-errors/errors"
)
var (
marionette net.Conn
ffCommandCount = 0
defaultFFPrefs = map[string]string{
"browser.startup.homepage": "'https://www.google.com'",
"startup.homepage_welcome_url": "'https://www.google.com'",
"startup.homepage_welcome_url.additional": "''",
"devtools.errorconsole.enabled": "true",
"devtools.chrome.enabled": "true",
// Send Browser Console (different from Devtools console) output to
// STDOUT.
"browser.dom.window.dump.enabled": "true",
// From:
// http://hg.mozilla.org/mozilla-central/file/1dd81c324ac7/build/automation.py.in//l388
// Make url-classifier updates so rare that they won"t affect tests.
"urlclassifier.updateinterval": "172800",
// Point the url-classifier to a nonexistent local URL for fast failures.
"browser.safebrowsing.provider.0.gethashURL": "'http://localhost/safebrowsing-dummy/gethash'",
"browser.safebrowsing.provider.0.keyURL": "'http://localhost/safebrowsing-dummy/newkey'",
"browser.safebrowsing.provider.0.updateURL": "'http://localhost/safebrowsing-dummy/update'",
// Disable self repair/SHIELD
"browser.selfsupport.url": "'https://localhost/selfrepair'",
// Disable Reader Mode UI tour
"browser.reader.detectedFirstArticle": "true",
// Set the policy firstURL to an empty string to prevent
// the privacy info page to be opened on every "web-ext run".
// (See #1114 for rationale)
"datareporting.policy.firstRunURL": "''",
}
)
func startHeadlessFirefox() {
Log("Starting Firefox in headless mode")
firefoxPath := Shell("which " + *firefoxBinary)

View file

@ -8,11 +8,13 @@ import (
"github.com/gdamore/tcell"
)
// Frame is a single frame for the entire DOM. The TTY is merely a window onto a
// A frame is a single snapshot of the DOM. The TTY is merely a window onto a
// region of this frame.
type Frame struct {
type frame struct {
width int
height int
xScroll int
yScroll int
pixels [][2]tcell.Color
text [][]rune
textColours []tcell.Color
@ -25,40 +27,99 @@ type cell struct {
bgColour tcell.Color
}
// Text frames received from the webextension are 1 dimensional arrays of strings.
// They are made up of a repeating pattern of 4 items:
// ["RED", "GREEN", "BLUE", "CHARACTER" ...]
func (f *Frame) parseJSONFrameText(jsonString string) {
if (len(f.pixels) == 0) { f.preFillPixelsWithBlack() }
var index, textIndex int
var frame []string
type incomingFrameText struct {
TabID int `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
Text []string `json:"text"`
Colours []int32 `json:"colours"`
}
// TODO: Can these be sent as binary blobs?
type incomingFramePixels struct {
TabID int `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
Colours []int32 `json:"colours"`
}
func (f *frame) rowCount() int {
return f.height / 2
}
func parseJSONFrameText(jsonString string) {
var incoming incomingFrameText
jsonBytes := []byte(jsonString)
if err := json.Unmarshal(jsonBytes, &frame); err != nil {
if err := json.Unmarshal(jsonBytes, &incoming); err != nil {
Shutdown(err)
}
if (len(frame) < f.width * (f.height / 2 ) * 4) {
ensureTabExists(incoming.TabID)
tabs[incoming.TabID].frame.buildFrameText(incoming)
}
func (f *frame) buildFrameText(incoming incomingFrameText) {
f.setup(incoming.Width, incoming.Height)
if (len(f.pixels) == 0) { f.preFillPixels() }
if (!f.isIncomingFrameTextValid(incoming)) { return }
CurrentTab = tabs[incoming.TabID]
f.populateFrameText(incoming)
}
func parseJSONFramePixels(jsonString string) {
var incoming incomingFramePixels
jsonBytes := []byte(jsonString)
if err := json.Unmarshal(jsonBytes, &incoming); err != nil {
Shutdown(err)
}
ensureTabExists(incoming.TabID)
if (len(tabs[incoming.TabID].frame.text) == 0) { return }
tabs[incoming.TabID].frame.buildFramePixels(incoming)
}
func (f *frame) buildFramePixels(incoming incomingFramePixels) {
f.setup(incoming.Width, incoming.Height)
if (!f.isIncomingFramePixelsValid(incoming)) { return }
f.populateFramePixels(incoming)
}
func (f *frame) setup(width, height int) {
f.width = width
f.height = height
f.resetCells()
}
func (f *frame) resetCells() {
f.cells = make([]cell, (f.rowCount()) * f.width)
}
func (f *frame) isIncomingFrameTextValid(incoming incomingFrameText) bool {
if (len(incoming.Text) < f.width * (f.rowCount())) {
Log(
fmt.Sprintf(
"Not parsing small text frame. Data length: %d, current dimensions: %dx(%d/2)*4=%d",
len(frame),
"Not parsing small text frame. Data length: %d, current dimensions: %dx(%d/2)=%d",
len(incoming.Text),
f.width,
f.height,
f.width * (f.height / 2 ) * 4))
return
f.width * (f.rowCount())))
return false
}
f.cells = make([]cell, (f.height / 2) * f.width)
f.text = make([][]rune, (f.height / 2) * f.width)
f.textColours = make([]tcell.Color, (f.height / 2) * f.width)
for y := 0; y < f.height / 2; y++ {
return true
}
func (f *frame) populateFrameText(incoming incomingFrameText) {
var index, colourIndex int
f.text = make([][]rune, (f.rowCount()) * f.width)
f.textColours = make([]tcell.Color, (f.rowCount()) * f.width)
for y := 0; y < f.rowCount(); y++ {
for x := 0; x < f.width; x++ {
index = ((f.width * y) + x)
textIndex = index * 4
colourIndex = index * 3
f.textColours[index] = tcell.NewRGBColor(
toInt32(frame[textIndex + 0]),
toInt32(frame[textIndex + 1]),
toInt32(frame[textIndex + 2]),
incoming.Colours[colourIndex + 0],
incoming.Colours[colourIndex + 1],
incoming.Colours[colourIndex + 2],
)
f.text[index] = []rune(frame[textIndex + 3])
f.text[index] = []rune(incoming.Text[index])
f.buildCell(x, y);
}
}
@ -66,40 +127,21 @@ func (f *Frame) parseJSONFrameText(jsonString string) {
// This covers the rare situation where a text frame has been sent before any pixel
// data has been populated.
func (f *Frame) preFillPixelsWithBlack() {
func (f *frame) preFillPixels() {
f.pixels = make([][2]tcell.Color, f.height * f.width)
for i := range f.pixels {
f.pixels[i] = [2]tcell.Color{
tcell.NewRGBColor(0, 0, 0),
tcell.NewRGBColor(0, 0, 0),
tcell.NewRGBColor(255, 255, 255),
tcell.NewRGBColor(255, 255, 255),
}
}
}
// Pixel frames received from the webextension are 1 dimensional arrays of strings.
// They are made up of a repeating pattern of 6 items:
// ["FG RED", "FG GREEN", "FG BLUE", "BG RED", "BG GREEN", "BG BLUE" ...]
// TODO: Can these be sent as binary blobs?
func (f *Frame) parseJSONFramePixels(jsonString string) {
if (len(f.text) == 0) { return }
func (f *frame) populateFramePixels(incoming incomingFramePixels) {
var index, indexFg, indexBg, pixelIndexFg, pixelIndexBg int
var frame []string
jsonBytes := []byte(jsonString)
if err := json.Unmarshal(jsonBytes, &frame); err != nil {
Shutdown(err)
}
if (len(frame) != f.width * f.height * 3) {
Log(
fmt.Sprintf(
"Not parsing pixels frame. Data length: %d, current dimensions: %dx%d*3=%d",
len(frame),
f.width,
f.height,
f.width * f.height * 3))
return
}
f.cells = make([]cell, (f.height / 2) * f.width)
f.resetCells()
f.pixels = make([][2]tcell.Color, f.height * f.width)
data := incoming.Colours
for y := 0; y < f.height; y += 2 {
for x := 0; x < f.width; x++ {
index = (f.width * (y / 2)) + x
@ -109,14 +151,14 @@ func (f *Frame) parseJSONFramePixels(jsonString string) {
pixelIndexFg = indexFg * 3
pixels := [2]tcell.Color{
tcell.NewRGBColor(
toInt32(frame[pixelIndexBg + 0]),
toInt32(frame[pixelIndexBg + 1]),
toInt32(frame[pixelIndexBg + 2]),
data[pixelIndexBg + 0],
data[pixelIndexBg + 1],
data[pixelIndexBg + 2],
),
tcell.NewRGBColor(
toInt32(frame[pixelIndexFg + 0]),
toInt32(frame[pixelIndexFg + 1]),
toInt32(frame[pixelIndexFg + 2]),
data[pixelIndexFg + 0],
data[pixelIndexFg + 1],
data[pixelIndexFg + 2],
),
}
f.pixels[index] = pixels
@ -125,14 +167,27 @@ func (f *Frame) parseJSONFramePixels(jsonString string) {
}
}
func (f *frame) isIncomingFramePixelsValid(incoming incomingFramePixels) bool {
if (len(incoming.Colours) != f.width * f.height * 3) {
Log(
fmt.Sprintf(
"Not parsing pixels frame. Data length: %d, current dimensions: %dx%d*3=%d",
len(incoming.Colours),
f.width,
f.height,
f.width * f.height * 3))
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) {
func (f *frame) buildCell(x int, y int) {
index := ((f.width * y) + x)
if (index >= len(f.pixels)) { return } // TODO: There must be a better way
character, fgColour := f.getCharacterAt(index)
pixelFg, bgColour := f.getPixelColoursAt(index)
if (isCharacterTransparent(character)) {
@ -142,13 +197,13 @@ func (f *Frame) buildCell(x int, y int) {
f.addCell(index, fgColour, bgColour, character)
}
func (f *Frame) getCharacterAt(index int) ([]rune, tcell.Color) {
func (f *frame) getCharacterAt(index int) ([]rune, tcell.Color) {
character := f.text[index]
colour := f.textColours[index]
return character, colour
}
func (f *Frame) getPixelColoursAt(index int) (tcell.Color, tcell.Color) {
func (f *frame) getPixelColoursAt(index int) (tcell.Color, tcell.Color) {
bgColour := f.pixels[index][0]
fgColour := f.pixels[index][1]
return fgColour, bgColour
@ -158,7 +213,7 @@ func isCharacterTransparent(character []rune) bool {
return string(character) == "" || unicode.IsSpace(character[0]);
}
func (f *Frame) addCell(index int, fgColour, bgColour tcell.Color, character []rune) {
func (f *frame) addCell(index int, fgColour, bgColour tcell.Color, character []rune) {
newCell := cell{
fgColour: fgColour,
bgColour: bgColour,
@ -166,3 +221,9 @@ func (f *Frame) addCell(index int, fgColour, bgColour tcell.Color, character []r
}
f.cells[index] = newCell
}
func (f *frame) limitScroll(height int) {
maxYScroll := f.rowCount() - height
if (f.yScroll > maxYScroll) { f.yScroll = maxYScroll }
if (f.yScroll < 0) { f.yScroll = 0 }
}

View file

@ -2,7 +2,6 @@ package browsh
import (
"testing"
"encoding/json"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
@ -13,29 +12,37 @@ func TestFrameBuilder(t *testing.T) {
RunSpecs(t, "Frame builder tests")
}
var testFrame Frame
var testFrame *frame
var frameJSONText, _ = json.Marshal([]string{
"77", "77", "77", "A",
"101", "101", "101", "b",
"102", "102", "102", "c",
"103", "103", "103", "",
})
var frameText = string(frameJSONText)
var frameJSONPixels, _ = json.Marshal([]string{
"254", "254", "254", "111", "111", "111",
"1", "1", "1", "2", "2", "2",
"3", "3", "3", "4", "4", "4",
"123", "123", "123", "200", "200", "200",
})
var framePixels = string(frameJSONPixels)
var frameJSONText = `{
"id": 1,
"width": 2,
"height": 4,
"text": ["A", "b", "c", ""],
"colours": [
77, 77, 77,
101, 101, 101,
102, 102, 102,
103, 103, 103
]
}`
var frameJSONPixels = `{
"id": 1,
"width": 2,
"height": 4,
"colours": [
254, 254, 254, 111, 111, 111,
1, 1, 1, 2, 2, 2,
3, 3, 3, 4, 4, 4,
123, 123, 123, 200, 200, 200
]
}`
var _ = Describe("Frame struct", func() {
BeforeEach(func() {
testFrame = Frame{width: 2, height: 4}
testFrame.parseJSONFrameText(frameText)
parseJSONFrameText(frameJSONText)
testFrame = &tabs[1].frame
})
It("should parse JSON frame text", func() {
@ -47,7 +54,7 @@ var _ = Describe("Frame struct", func() {
It("should parse JSON pixels (for text-less cells)", func() {
var r, g, b int32
testFrame.parseJSONFramePixels(framePixels)
parseJSONFramePixels(frameJSONPixels)
r, g, b = testFrame.cells[3].fgColour.RGB()
Expect([3]int32{r, g, b}).To(Equal([3]int32{200, 200, 200}))
r, g, b = testFrame.cells[3].bgColour.RGB()
@ -56,7 +63,7 @@ var _ = Describe("Frame struct", func() {
It("should parse JSON pixels (using text for foreground)", func() {
var r, g, b int32
testFrame.parseJSONFramePixels(framePixels)
parseJSONFramePixels(frameJSONPixels)
r, g, b = testFrame.cells[0].fgColour.RGB()
Expect([3]int32{r, g, b}).To(Equal([3]int32{77, 77, 77}))
r, g, b = testFrame.cells[0].bgColour.RGB()

View file

@ -0,0 +1,53 @@
package browsh
import (
"encoding/json"
)
var tabs = make(map[int]*tab)
// CurrentTab is the currently active tab in the TTY browser
var CurrentTab *tab
// A single tab synced from the browser
type tab struct {
ID int `json:"id"`
Title string `json:"title"`
URI string `json:"uri"`
PageState string `json:"page_state"`
StatusMessage string `json:"status_message"`
frame frame
}
func ensureTabExists(id int) {
if _, ok := tabs[id]; !ok {
tabs[id] = &tab{ID: id}
}
}
func parseJSONTabState(jsonString string) {
var incoming tab
jsonBytes := []byte(jsonString)
if err := json.Unmarshal(jsonBytes, &incoming); err != nil {
Shutdown(err)
}
ensureTabExists(incoming.ID)
tabs[incoming.ID].handleStateChange(incoming)
}
func (t *tab) handleStateChange(incoming tab) {
if (t.PageState != incoming.PageState) {
// TODO: Take the browser's scroll events as lead
if (incoming.PageState == "page_init") {
t.frame.yScroll = 0
}
}
// TODO: What's the idiomatic Golang way to do this?
t.Title = incoming.Title
t.URI = incoming.URI
t.PageState = incoming.PageState
t.StatusMessage = incoming.StatusMessage
renderUI()
}

View file

@ -12,6 +12,8 @@ import (
var (
xScroll = 0
yScroll = 0
screen tcell.Screen
uiHeight = 2
)
func setupTcell() {
@ -52,8 +54,8 @@ func readStdin() {
button := ev.Buttons()
eventMap := map[string]interface{}{
"button": int(button),
"mouse_x": int(x + xScroll),
"mouse_y": int(y - uiHeight + yScroll),
"mouse_x": int(x + CurrentTab.frame.xScroll),
"mouse_y": int(y - uiHeight + CurrentTab.frame.yScroll),
"modifiers": int(ev.Modifiers()),
}
marshalled, _ := json.Marshal(eventMap)
@ -73,33 +75,27 @@ func handleUserKeyPress(ev *tcell.EventKey) {
}
func handleScrolling(ev *tcell.EventKey) {
yScrollOriginal := yScroll
yScrollOriginal := CurrentTab.frame.yScroll
_, height := screen.Size()
height -= uiHeight
if ev.Key() == tcell.KeyUp {
yScroll -= 2
CurrentTab.frame.yScroll -= 2
}
if ev.Key() == tcell.KeyDown {
yScroll += 2
CurrentTab.frame.yScroll += 2
}
if ev.Key() == tcell.KeyPgUp {
yScroll -= height
CurrentTab.frame.yScroll -= height
}
if ev.Key() == tcell.KeyPgDn {
yScroll += height
CurrentTab.frame.yScroll += height
}
limitScroll(height)
if (yScroll != yScrollOriginal) {
renderFrame()
CurrentTab.frame.limitScroll(height)
if (CurrentTab.frame.yScroll != yScrollOriginal) {
renderCurrentTabWindow()
}
}
func limitScroll(height int) {
maxYScroll := (frame.height / 2) - height
if (yScroll > maxYScroll) { yScroll = maxYScroll }
if (yScroll < 0) { yScroll = 0 }
}
// Write a simple text string to the screen. Not for use in the browser frames
// themselves. If you want anything to appear in the browser that must be done
// through the webextension.
@ -111,42 +107,30 @@ func writeString(x, y int, str string) {
screen.SetContent(x, y, c, nil, defaultColours)
x++
}
screen.Sync()
screen.Show()
}
func renderAll() {
renderUI()
renderFrame()
}
// Render the tabs and URL bar
// TODO: Temporary function, UI rendering should all be moved into this CLI app
func renderUI() {
return
var styling = tcell.StyleDefault
var runeChars []rune
width, _ := screen.Size()
index := 0
for y := 0; y < uiHeight ; y++ {
for x := 0; x < width; x++ {
styling = styling.Foreground(frame.cells[index].fgColour)
styling = styling.Background(frame.cells[index].bgColour)
runeChars = frame.cells[index].character
index++
screen.SetCell(x, y, styling, runeChars[0])
}
}
screen.Show()
renderCurrentTabWindow()
}
// Tcell uses a buffer to collect screen updates on, it only actually sends
// ANSI rendering commands to the terminal when we tell it to. And even then it
// will try to minimise rendering commands by only rendering parts of the terminal
// that have changed.
func renderFrame() {
if (len(frame.pixels) == 0 || len(frame.text) == 0) { return }
func renderCurrentTabWindow() {
if (len(CurrentTab.frame.pixels) == 0 || len(CurrentTab.frame.text) == 0) {
Log("Not rendering frame without complimentary pixels and text:")
Log(
fmt.Sprintf(
"pixels: %d, text: %d",
len(CurrentTab.frame.pixels), len(CurrentTab.frame.text)))
return
}
var styling = tcell.StyleDefault
var runeChars []rune
frame := &CurrentTab.frame
width, height := screen.Size()
if (frame.width == 0 || frame.height == 0) {
Log("Not rendering frame with a zero dimension")
@ -155,38 +139,24 @@ func renderFrame() {
index := 0
for y := 0; y < height - uiHeight; y++ {
for x := 0; x < width; x++ {
index = ((y + yScroll) * frame.width) + ((x + xScroll))
if (!checkCell(index, x + xScroll, y + yScroll)) { return }
index = ((y + frame.yScroll) * frame.width) + ((x + frame.xScroll))
if (!checkCell(index, x + frame.xScroll, y + frame.yScroll)) { return }
styling = styling.Foreground(frame.cells[index].fgColour)
styling = styling.Background(frame.cells[index].bgColour)
runeChars = frame.cells[index].character
if (len(runeChars) == 0) { continue } // TODO: shouldn't need this
// TODO: do this is in isCharacterTransparent()
if (len(runeChars) == 0) { continue }
screen.SetCell(x, y + uiHeight, styling, runeChars[0])
}
}
overlayPageStatusMessage(height)
screen.Show()
}
func overlayPageStatusMessage(height int) {
message := State["page_status_message"]
x := 0
fg := tcell.NewHexColor(int32(0xffffff))
bg := tcell.NewHexColor(int32(0x000000))
style := tcell.StyleDefault
style.Foreground(fg)
style.Foreground(bg)
for _, c := range message {
screen.SetCell(x, height - 1, style, c)
x++
}
}
func checkCell(index, x, y int) bool {
if (index >= len(frame.cells)) {
if (index >= len(CurrentTab.frame.cells)) {
message := fmt.Sprintf(
"Blank frame data (size: %d) at %dx%d, index: %d",
len(frame.cells), x, y, index)
len(CurrentTab.frame.cells), x, y, index)
Log(message)
return false;
}

View file

@ -0,0 +1,46 @@
package browsh
import (
"unicode/utf8"
)
// Render tabs, URL bar, status messages, etc
func renderUI() {
renderTabs()
renderURLBar()
overlayPageStatusMessage()
}
func fillLineToEnd(x, y int) {
width, _ := screen.Size()
for i := x; i < width - 1; i++ {
writeString(i, y, " ")
}
}
func renderTabs() {
count := 0
xPosition := 0
tabTitleLength := 15
for _, tab := range tabs {
if (tab.frame.text == nil) { continue } // TODO: this shouldn't be needed
tabTitle := []rune(tab.Title)
tabTitleContent := string(tabTitle[0:tabTitleLength]) + " |x "
writeString(xPosition, 0, tabTitleContent)
count++
xPosition = (count * tabTitleLength) + 4
}
fillLineToEnd(xPosition, 0)
}
func renderURLBar() {
content := " ← | x | " + CurrentTab.URI
writeString(0, 1, content)
fillLineToEnd(utf8.RuneCountInString(content) - 1, 0)
}
func overlayPageStatusMessage() {
_, height := screen.Size()
writeString(0, height - 1, CurrentTab.StatusMessage)
}

View file

@ -89,7 +89,7 @@ func WaitForText(text string, x, y int) {
func WaitForPageLoad() {
start := time.Now()
for time.Since(start) < perTestTimeout {
if browsh.State["page_state"] == "parsing_complete" {
if browsh.CurrentTab.PageState == "parsing_complete" {
time.Sleep(100 * time.Millisecond)
return
}

View file

@ -13,8 +13,11 @@ export default (MixinBase) => class extends MixinBase {
}
sendState() {
const state = _.mapValues(this.state, (v) => { return v.toString() });
this.sendToTerminal(`/state,${JSON.stringify(state)}`);
let state = _.mapValues(this.state, (v) => { return v.toString() });
state.id = this.currentTab().id;
state.title = this.currentTab().title;
state.uri = this.currentTab().url;
this.sendToTerminal(`/tab_state,${JSON.stringify(state)}`);
}
log(...messages) {

View file

@ -110,6 +110,7 @@ export default class extends utils.mixins(CommonMixin, TTYCommandsMixin, TabComm
// TODO: Can we not just assume that channel.name is the same as this.active_tab_id?
this.log(`Tab ${channel.name} connected for communication with background process`);
this.tabs[channel.name] = {
id: parseInt(channel.name),
channel: channel
};
channel.onMessage.addListener(this.handleTabMessage.bind(this));

View file

@ -1,5 +1,3 @@
import charWidthInTTY from 'string-width';
import utils from 'utils';
// Handle commands from tabs, like sending a frame or information about
@ -17,21 +15,21 @@ export default (MixinBase) => class extends MixinBase {
this.sendToTerminal(`/frame_pixels,${message.slice(14)}`);
break;
case '/tab_info':
this.currentTab().info = JSON.parse(utils.rebuildArgsToSingleArg(parts));
incoming = JSON.parse(utils.rebuildArgsToSingleArg(parts));
this.currentTab().title = incoming.title
this.currentTab().url = incoming.url
this.sendState();
break;
case '/dimensions':
incoming = JSON.parse(message.slice(12));
this._mightResizeWindow(incoming);
this.dimensions = incoming;
this._sendFrameSize();
break;
case '/status':
if (this._current_frame) {
this.updateStatus(parts[1]);
this.sendState();
}
this.updateStatus(parts[1]);
this.sendState();
break;
case `/log`:
case '/log':
this.log(message.slice(5));
break;
default:
@ -47,18 +45,11 @@ export default (MixinBase) => class extends MixinBase {
}
}
_sendFrameSize() {
this.state['frame_width'] = this.dimensions.frame.width;
this.state['frame_height'] = this.dimensions.frame.height;
this.sendState();
}
updateStatus(status, message = '') {
if (typeof this._current_frame === 'undefined') return;
let status_message;
switch (status) {
case 'page_init':
status_message = `Loading ${this.currentTab().info.url}`;
status_message = `Loading ${this.currentTab().url}`;
break;
case 'parsing_complete':
status_message = '';
@ -70,52 +61,7 @@ export default (MixinBase) => class extends MixinBase {
if (message != '') status_message = message;
}
this.state['page_state'] = status;
this.state['page_status_message'] = status_message;
this.state['status_message'] = status_message;
this.sendState();
}
_applyUI() {
const tabs = this._buildTTYRow(this._buildTabs());
const urlBar = this._buildURLBar();
this._current_frame = tabs.concat(urlBar).concat(this._current_frame);
}
_buildTabs() {
return this.currentTab().info.title.trim();
}
_buildTTYRow(text) {
let char, char_width;
let row = [];
for (let index = 0; index < this.tty_width; index++) {
if (index < text.length) {
char = text[index];
} else {
char = " "
}
char_width = charWidthInTTY(char);
if (char_width === 0) {
char = " ";
}
if (char_width > 1) {
index += char_width - 1;
}
row = row.concat(utils.ttyPlainCell(char));
for (var padding = 0; padding < char_width - 1; padding++) {
row = row.concat(utils.ttyPlainCell(" "));
}
}
return row;
}
_buildURLBar() {
let content;
if (this.isURLBarFocused) {
content = this.urlBarUserContent;
} else {
content = this.currentTab().info.url;
}
content = ' ← | x | ' + content;
return this._buildTTYRow(content);
}
};

View file

@ -5,8 +5,6 @@ import utils from 'utils';
export default (MixinBase) => class extends MixinBase {
constructor() {
super();
this.isURLBarFocused = false;
this.urlBarUserContent = "";
}
handleTerminalMessage(message) {
@ -28,24 +26,14 @@ export default (MixinBase) => class extends MixinBase {
this.sendToCurrentTab('/request_frame')
}, 250);
break;
case '/status':
this.updateStatus('', parts.slice(1).join(','));
case '/url_bar':
this._handleURLBarInput(parts.slice(1).join(','));
break;
}
}
_handleUICommand(parts) {
const input = JSON.parse(utils.rebuildArgsToSingleArg(parts));
if (this.isURLBarFocused) {
this._handleURLBarInput(input);
return true;
}
switch(input.key) {
case 12: // CTRL+L
this.isURLBarFocused = true;
this.urlBarUserContent = "";
return true;
}
if (input.mod === 4) {
switch(input.char) {
case 'P':
@ -62,9 +50,6 @@ export default (MixinBase) => class extends MixinBase {
case x > 3 && x < 6:
this.sendToCurrentTab('/window_stop');
break;
default:
this.urlBarUserContent = "";
this.isURLBarFocused = true;
}
return true;
}
@ -72,30 +57,13 @@ export default (MixinBase) => class extends MixinBase {
}
_handleURLBarInput(input) {
let char = input.char;
switch (input.key) {
case 12: // CTRL+L
this.isURLBarFocused = false;
return;
case 13: // enter
this.sendToCurrentTab(`/url,${this._getURLfromUserInput()}`);
this.isURLBarFocused = false;
return;
case 32: // spacebar
char = " ";
break;
case 127: // backspace
this.urlBarUserContent = this.urlBarUserContent.slice(0, -1);
return;
}
if (typeof char === 'undefined') return;
this.urlBarUserContent += char;
const final_url = this._getURLfromUserInput(input);
this.sendToCurrentTab(`/url,${final_url}`);
}
_getURLfromUserInput() {
_getURLfromUserInput(input) {
let url;
const search_engine = 'https://www.google.com/search?q=';
let input = this.urlBarUserContent;
// Basically just check to see if there is text either side of a dot
const is_straddled_dot = RegExp(/^[^\s]+\.[^\s]+/);
// More comprehensive URL pattern

View file

@ -18,7 +18,9 @@ export default class extends utils.mixins(CommonMixin) {
sendFrame() {
this.getScaledScreenshot();
this._serialiseFrame();
if (this.frame.length > 0) {
this.frame.width = this.dimensions.frame.width;
this.frame.height = this.dimensions.frame.height;
if (this.frame.colours.length > 0) {
this.sendMessage(`/frame_pixels,${JSON.stringify(this.frame)}`);
} else {
this.log("Not sending empty pixels frame");
@ -164,12 +166,16 @@ export default class extends utils.mixins(CommonMixin) {
}
_serialiseFrame() {
this.frame = [];
this.frame = {
id: parseInt(this.channel.name),
colours: []
};
const height = this.dimensions.frame.height;
const width = this.dimensions.frame.width;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
this.getScaledPixelAt(x, y).map((c) => this.frame.push(c.toString()));
// TODO: Explore sending as binary data
this.getScaledPixelAt(x, y).map((c) => this.frame.colours.push(c));
}
}
}

View file

@ -14,7 +14,6 @@ export default class extends utils.mixins(CommonMixin) {
this.dimensions = dimensions;
this.graphics_builder = graphics_builder;
this.tty_grid = new TTYGrid(dimensions, graphics_builder);
this.frame = [];
this._parse_started_elements = [];
// A `range` is the DOM's representation of elements and nodes as they are rendered in
// the DOM. Think of the 'range' that is created when you select/highlight text for
@ -25,7 +24,9 @@ export default class extends utils.mixins(CommonMixin) {
sendFrame() {
this.buildFormattedText();
this._serialiseFrame();
if (this.frame.length > 0) {
this.frame.width = this.dimensions.frame.width;
this.frame.height = this.dimensions.frame.height;
if (this.frame.text.length > 0) {
this.sendMessage(`/frame_text,${JSON.stringify(this.frame)}`);
} else {
this.log("Not sending empty text frame");
@ -297,7 +298,11 @@ export default class extends utils.mixins(CommonMixin) {
__serialiseFrame() {
let cell, index;
this.frame = [];
this.frame = {
id: parseInt(this.channel.name),
text: [],
colours: []
};
const height = this.dimensions.frame.height / 2;
const width = this.dimensions.frame.width;
for (let y = 0; y < height; y++) {
@ -305,13 +310,13 @@ export default class extends utils.mixins(CommonMixin) {
index = (y * width) + x;
cell = this.tty_grid.cells[index];
if (cell === undefined) {
this.frame.push("0")
this.frame.push("0")
this.frame.push("0")
this.frame.push("")
this.frame.colours.push(0)
this.frame.colours.push(0)
this.frame.colours.push(0)
this.frame.text.push("")
} else {
cell.fg_colour.map((c) => this.frame.push(c.toString()));
this.frame.push(cell.rune);
cell.fg_colour.map((c) => this.frame.colours.push(c));
this.frame.text.push(cell.rune);
}
}
}

View file

@ -9,7 +9,7 @@ let graphics_builder;
function setup() {
let dimensions = new Dimensions();
graphics_builder = new GraphicsBuilder(undefined, dimensions);
graphics_builder = new GraphicsBuilder({name: "1"}, dimensions);
graphics_builder.getScaledScreenshot();
}
@ -22,10 +22,11 @@ describe('Graphics Builder', () => {
it('should serialise a scaled frame', () => {
graphics_builder._serialiseFrame();
expect(graphics_builder.frame.length).to.equal(36);
expect(graphics_builder.frame[0]).to.equal('111');
expect(graphics_builder.frame[3]).to.equal('0');
expect(graphics_builder.frame[32]).to.equal('111');
expect(graphics_builder.frame[35]).to.equal('0');
const colours = graphics_builder.frame.colours
expect(colours.length).to.equal(36);
expect(colours[0]).to.equal(111);
expect(colours[3]).to.equal(0);
expect(colours[32]).to.equal(111);
expect(colours[35]).to.equal(0);
});
});

View file

@ -13,11 +13,12 @@ import {
} from 'fixtures/canvas_pixels';
let graphics_builder, text_builder;
let channel = {name: 1};
function setup() {
let dimensions = new Dimensions();
graphics_builder = new GraphicsBuilder(undefined, dimensions);
text_builder = new TextBuilder(undefined, dimensions, graphics_builder);
graphics_builder = new GraphicsBuilder(channel, dimensions);
text_builder = new TextBuilder(channel, dimensions, graphics_builder);
graphics_builder.getScreenshotWithText();
graphics_builder.getScreenshotWithoutText();
graphics_builder.getScaledScreenshot();
@ -68,12 +69,15 @@ describe('Text Builder', () => {
it('should serialise a frame', () => {
text_builder._serialiseFrame();
expect(text_builder.frame).to.deep.equal([
'255', '255', '255', 't',
'255', '255', '255', 'e',
'255', '255', '255', 's',
'255', '255', '255', 'n',
'0', '0', '0', '', '0', '0', '0', ''
expect(text_builder.frame.colours).to.deep.equal([
255, 255, 255,
255, 255, 255,
255, 255, 255,
255, 255, 255,
0, 0, 0, 0, 0, 0
]);
expect(text_builder.frame.text).to.deep.equal([
"t", "e", "s", "n", "", ""
]);
});
});