tconsole: add status bar

This adds a status bar to the bottom of the tconsole. It contains a page
selector and clock.

Change-Id: Ia932fe793ff067f3d096046d8bd93c060bac807a
Reviewed-on: https://review.monogon.dev/c/monogon/+/3381
Tested-by: Jenkins CI
Reviewed-by: Leopold Schabel <leo@monogon.tech>
diff --git a/metropolis/node/core/tconsole/BUILD.bazel b/metropolis/node/core/tconsole/BUILD.bazel
index c8ca99e..fadc7f3 100644
--- a/metropolis/node/core/tconsole/BUILD.bazel
+++ b/metropolis/node/core/tconsole/BUILD.bazel
@@ -6,6 +6,7 @@
         "colors.go",
         "draw.go",
         "page_status.go",
+        "statusbar.go",
         "tconsole.go",
     ],
     importpath = "source.monogon.dev/metropolis/node/core/tconsole",
diff --git a/metropolis/node/core/tconsole/draw.go b/metropolis/node/core/tconsole/draw.go
index d88e35b..4fec256 100644
--- a/metropolis/node/core/tconsole/draw.go
+++ b/metropolis/node/core/tconsole/draw.go
@@ -8,13 +8,15 @@
 )
 
 // drawText draws a single line of text from left to right, starting at x and y.
-func (c *Console) drawText(x, y int, text string, style tcell.Style) {
+func (c *Console) drawText(x, y int, text string, style tcell.Style) int {
 	g := uniseg.NewGraphemes(text)
+	xi := 0
 	for g.Next() {
 		runes := g.Runes()
-		c.screen.SetContent(x, y, runes[0], runes[1:], style)
-		x += 1
+		c.screen.SetContent(x+xi, y, runes[0], runes[1:], style)
+		xi += 1
 	}
+	return xi
 }
 
 // drawTextCentered draw a single line of text from left to right, starting at a
diff --git a/metropolis/node/core/tconsole/page_status.go b/metropolis/node/core/tconsole/page_status.go
index 13c4b63..fbf4649 100644
--- a/metropolis/node/core/tconsole/page_status.go
+++ b/metropolis/node/core/tconsole/page_status.go
@@ -73,6 +73,4 @@
 	if drawLogo {
 		c.drawLogo(splitH-logoWidth, splitV+center(c.height-splitV, logoHeight), sty2)
 	}
-
-	c.screen.Show()
 }
diff --git a/metropolis/node/core/tconsole/statusbar.go b/metropolis/node/core/tconsole/statusbar.go
new file mode 100644
index 0000000..06841c2
--- /dev/null
+++ b/metropolis/node/core/tconsole/statusbar.go
@@ -0,0 +1,44 @@
+package tconsole
+
+import (
+	"time"
+
+	"github.com/gdamore/tcell/v2"
+)
+
+// draw button at coordinates containing text, with the left side of the button
+// at (x, y). The number of columns used up by the button is returned.
+func (c *Console) button(x, y int, caption string, selected bool, sty tcell.Style) int {
+	fg, bg, _ := sty.Decompose()
+	styInv := sty.Background(fg).Foreground(bg)
+
+	xi := 1
+	if selected {
+		c.screen.SetContent(x+xi, y, tcell.RuneBlock, nil, sty)
+		xi += 1
+		xi += c.drawText(x+xi, y, caption, styInv)
+		c.screen.SetContent(x+xi, y, tcell.RuneBlock, nil, sty)
+		xi += 1
+	} else {
+		c.screen.SetContent(x+xi, y, ' ', nil, sty)
+		xi += 1
+		xi += c.drawText(x+xi, y, caption, sty)
+		c.screen.SetContent(x+xi, y, ' ', nil, sty)
+		xi += 1
+	}
+	return xi
+}
+
+// statusBar draw the main status bar at the bottom of the screen, containing
+// page switching buttons and a clock.
+func (c *Console) statusBar(active int, opts ...string) {
+	sty1 := tcell.StyleDefault.Background(c.color(colorBlue)).Foreground(c.color(colorBlack))
+	sty2 := tcell.StyleDefault.Background(c.color(colorPink)).Foreground(c.color(colorBlack))
+	x := 0
+	x += c.drawText(x, c.height-1, " Page (tab to switch): ", sty1)
+	for i, opt := range opts {
+		x += c.button(x, c.height-1, opt, i == active, sty2)
+	}
+
+	c.drawText(c.width-len(time.DateTime)-1, c.height-1, time.Now().Format(time.DateTime), sty1)
+}
diff --git a/metropolis/node/core/tconsole/tconsole.go b/metropolis/node/core/tconsole/tconsole.go
index 52885bd..963be34 100644
--- a/metropolis/node/core/tconsole/tconsole.go
+++ b/metropolis/node/core/tconsole/tconsole.go
@@ -5,6 +5,7 @@
 	"crypto/sha256"
 	"encoding/hex"
 	"strings"
+	"time"
 
 	"github.com/gdamore/tcell/v2"
 
@@ -29,6 +30,9 @@
 	height  int
 	// palette chosen for the given terminal.
 	palette palette
+	// activePage expressed within [0...num pages). The number/layout of pages is
+	// constructed dynamically in Run.
+	activePage int
 
 	network     event.Value[*network.Status]
 	roles       event.Value[*cpb.NodeRoles]
@@ -65,15 +69,17 @@
 	}
 
 	width, height := screen.Size()
+
 	return &Console{
-		ttyPath: ttyPath,
-		tty:     tty,
-		screen:  screen,
-		width:   width,
-		height:  height,
-		network: network,
-		palette: pal,
-		Quit:    make(chan struct{}),
+		ttyPath:    ttyPath,
+		tty:        tty,
+		screen:     screen,
+		width:      width,
+		height:     height,
+		network:    network,
+		palette:    pal,
+		Quit:       make(chan struct{}),
+		activePage: 0,
 
 		roles:       roles,
 		curatorConn: curatorConn,
@@ -92,6 +98,9 @@
 		if ev.Key() == tcell.KeyCtrlC {
 			close(c.Quit)
 		}
+		if ev.Key() == tcell.KeyTab {
+			c.activePage += 1
+		}
 	case *tcell.EventResize:
 		c.width, c.height = ev.Size()
 	}
@@ -120,6 +129,7 @@
 	}
 	supervisor.Signal(ctx, supervisor.SignalHealthy)
 
+	// Per-page data.
 	pageStatus := pageStatusData{
 		netAddr:     "Waiting...",
 		roles:       "Waiting...",
@@ -127,10 +137,32 @@
 		fingerprint: "Waiting...",
 	}
 
+	// Page references and names.
+	pages := []func(){
+		func() { c.pageStatus(&pageStatus) },
+	}
+	pageNames := []string{
+		"Status",
+	}
+
+	// Ticker used to maintain redraws at minimum 10Hz, to eg. update the clock in
+	// the status bar.
+	ticker := time.NewTicker(time.Second / 10)
+	defer ticker.Stop()
+
 	for {
-		c.pageStatus(&pageStatus)
+		// Draw active page.
+		c.activePage %= len(pages)
+		pages[c.activePage]()
+
+		// Draw status bar.
+		c.statusBar(c.activePage, pageNames...)
+
+		// Sync to screen.
+		c.screen.Show()
 
 		select {
+		case <-ticker.C:
 		case <-ctx.Done():
 			return ctx.Err()
 		case ev := <-evC: