Use a more sophisticated algorithm to distribute widget heights.

This is needed since there can now be an arbitrary number of widgets competing
for vertical space.

- Add a new MaxHeight method to the tk.Widget interface to provide hint on how
  to distribute the height.

- Implement the algorithm in distributeHeight in cli/app.go.
This commit is contained in:
Qi Xiao 2021-09-05 01:06:12 +01:00
parent db0b88f664
commit d2936c06a1
16 changed files with 228 additions and 45 deletions

View File

@ -4,6 +4,7 @@ package cli
import (
"io"
"os"
"sort"
"sync"
"syscall"
@ -259,7 +260,7 @@ func (a *app) redraw(flag redrawFlag) {
if hideRPrompt {
a.codeArea.MutateState(func(s *tk.CodeAreaState) { s.HideRPrompt = true })
}
bufMain := renderApp(a.codeArea, nil /* addon */, width, height)
bufMain := renderApp([]tk.Widget{a.codeArea /* no addon */}, width, height)
if hideRPrompt {
a.codeArea.MutateState(func(s *tk.CodeAreaState) { s.HideRPrompt = false })
}
@ -269,7 +270,7 @@ func (a *app) redraw(flag redrawFlag) {
a.TTY.UpdateBuffer(bufNotes, bufMain, flag&fullRedraw != 0)
a.TTY.ResetBuffer()
} else {
bufMain := renderApp(a.codeArea, addons, width, height)
bufMain := renderApp(append([]tk.Widget{a.codeArea}, addons...), width, height)
a.TTY.UpdateBuffer(bufNotes, bufMain, flag&fullRedraw != 0)
}
}
@ -291,18 +292,95 @@ func renderNotes(notes []string, width int) *term.Buffer {
}
// Renders the codearea, and uses the rest of the height for the listing.
func renderApp(codeArea tk.Renderer, addons []tk.Widget, width, height int) *term.Buffer {
buf := codeArea.Render(width, height)
for _, w := range addons {
if len(buf.Lines) >= height {
break
func renderApp(widgets []tk.Widget, width, height int) *term.Buffer {
heights, focus := distributeHeight(widgets, width, height)
var buf *term.Buffer
for i, w := range widgets {
if heights[i] == 0 {
continue
}
buf2 := w.Render(width, heights[i])
if buf == nil {
buf = buf2
} else {
buf.Extend(buf2, i == focus)
}
bufListing := w.Render(width, height-len(buf.Lines))
buf.Extend(bufListing, hasFocus(w))
}
return buf
}
// Distributes the height among all the widgets. Returns the height for each
// widget, and the index of the widget currently focused.
func distributeHeight(widgets []tk.Widget, width, height int) ([]int, int) {
var focus int
for i, w := range widgets {
if hasFocus(w) {
focus = i
}
}
n := len(widgets)
heights := make([]int, n)
if height <= n {
// Not enough (or just enough) height to render every widget with a
// height of 1.
remain := height
// Start from the focused widget, and extend downwards as much as
// possible.
for i := focus; i < n && remain > 0; i++ {
heights[i] = 1
remain--
}
// If there is still space remaining, start from the focused widget
// again, and extend upwards as much as possible.
for i := focus - 1; i >= 0 && remain > 0; i-- {
heights[i] = 1
remain--
}
return heights, focus
}
maxHeights := make([]int, n)
for i, w := range widgets {
maxHeights[i] = w.MaxHeight(width, height)
}
// The algorithm below achieves the following goals:
//
// 1. If maxHeights[u] > maxHeights[v], heights[u] >= heights[v];
//
// 2. While achieving goal 1, have as many widgets u s.t. heights[u] ==
// maxHeights[u].
//
// This is done by allocating the height among the widgets following an
// non-decreasing order of maxHeights. At each step:
//
// - If it's possible to allocate maxHeights[u] to all remaining widgets,
// then allocate maxHeights[u] to widget u;
//
// - If not, allocate the remaining budget evenly - rounding down at each
// step, so the widgets with smaller maxHeights gets smaller heights.
indices := make([]int, n)
for i := range indices {
indices[i] = i
}
sort.Slice(indices, func(i, j int) bool {
return maxHeights[indices[i]] < maxHeights[indices[j]]
})
remain := height
for rank, idx := range indices {
if remain >= maxHeights[idx] {
heights[idx] = maxHeights[idx]
} else {
heights[idx] = remain / (n - rank)
}
remain -= heights[idx]
}
return heights, focus
}
func hasFocus(w interface{}) bool {
if f, ok := w.(interface{ Focus() bool }); ok {
return f.Focus()

View File

@ -416,8 +416,9 @@ func TestReadCode_HidesAddonsWhenNotEnoughSpace(t *testing.T) {
})
defer f.Stop()
f.TestTTY(t, "\n",
term.DotHere, "addon1> ")
f.TestTTY(t,
"addon1> \n",
term.DotHere, "addon2> ")
}
type testAddon struct {

View File

@ -37,13 +37,21 @@ type histwalk struct {
}
func (w *histwalk) Render(width, height int) *term.Buffer {
cmd, _ := w.cursor.Get()
content := modeLine(fmt.Sprintf(" HISTORY #%d ", cmd.Seq), false)
buf := term.NewBufferBuilder(width).WriteStyled(content).Buffer()
buf := w.render(width)
buf.TrimToLines(0, height)
return buf
}
func (w *histwalk) MaxHeight(width, height int) int {
return len(w.render(width).Lines)
}
func (w *histwalk) render(width int) *term.Buffer {
cmd, _ := w.cursor.Get()
content := modeLine(fmt.Sprintf(" HISTORY #%d ", cmd.Seq), false)
return term.NewBufferBuilder(width).WriteStyled(content).Buffer()
}
func (w *histwalk) Handle(event term.Event) bool {
handled := w.Bindings.Handle(w, event)
if handled {

View File

@ -32,18 +32,26 @@ type instant struct {
}
func (w *instant) Render(width, height int) *term.Buffer {
buf := w.render(width, height)
buf.TrimToLines(0, height)
return buf
}
func (w *instant) MaxHeight(width, height int) int {
return len(w.render(width, height).Lines)
}
func (w *instant) render(width, height int) *term.Buffer {
bb := term.NewBufferBuilder(width).
WriteStyled(modeLine(" INSTANT ", false)).SetDotHere()
if w.lastErr != nil {
bb.Newline().Write(w.lastErr.Error(), ui.FgRed)
}
buf := bb.Buffer()
if len(buf.Lines) >= height {
buf.TrimToLines(0, height)
return buf
if len(buf.Lines) < height {
bufTextView := w.textView.Render(width, height-len(buf.Lines))
buf.Extend(bufTextView, false)
}
bufTextView := w.textView.Render(width, height-len(buf.Lines))
buf.Extend(bufTextView, false)
return buf
}

View File

@ -99,6 +99,10 @@ func (w *navigation) Render(width, height int) *term.Buffer {
return buf
}
func (w *navigation) MaxHeight(width, height int) int {
return w.codeArea.MaxHeight(width, height) + w.colView.MaxHeight(width, height)
}
func (w *navigation) Focus() bool {
return w.CopyState().Filtering
}

View File

@ -192,10 +192,8 @@ func testNavigation(t *testing.T, c NavigationCursor) {
" ++++++++++++++ -",
" d d2 │\n", Styles,
"#### ////////////// -",
" f d3 \n", Styles,
" f d3 ", Styles,
" ////////////// X",
" ", Styles,
" X",
)
f.TTY.TestBuffer(t, d1Buf2)

View File

@ -24,12 +24,20 @@ type stub struct {
}
func (w stub) Render(width, height int) *term.Buffer {
buf := term.NewBufferBuilder(width).
WriteStyled(modeLine(w.Name, false)).SetDotHere().Buffer()
buf := w.render(width)
buf.TrimToLines(0, height)
return buf
}
func (w stub) MaxHeight(width, height int) int {
return len(w.render(width).Lines)
}
func (w stub) render(width int) *term.Buffer {
return term.NewBufferBuilder(width).
WriteStyled(modeLine(w.Name, false)).SetDotHere().Buffer()
}
func (w stub) Handle(event term.Event) bool {
return w.Bindings.Handle(w, event)
}

View File

@ -146,12 +146,20 @@ func (w *codeArea) Submit() {
// Render renders the code area, including the prompt and rprompt, highlighted
// code, the cursor, and compilation errors in the code content.
func (w *codeArea) Render(width, height int) *term.Buffer {
b := w.render(width)
truncateToHeight(b, height)
return b
}
func (w *codeArea) MaxHeight(width, height int) int {
return len(w.render(width).Lines)
}
func (w *codeArea) render(width int) *term.Buffer {
view := getView(w)
bb := term.NewBufferBuilder(width)
renderView(view, bb)
b := bb.Buffer()
truncateToHeight(b, height)
return b
return bb.Buffer()
}
// Handle handles KeyEvent's of non-function keys, as well as PasteSetting

View File

@ -95,26 +95,47 @@ const colViewColGap = 1
// Render renders all the columns side by side, putting the dot in the focused
// column.
func (w *colView) Render(width, height int) *term.Buffer {
cols, widths := w.prepareRender(width)
if len(cols) == 0 {
return &term.Buffer{Width: width}
}
var buf term.Buffer
for i, col := range cols {
if i > 0 {
buf.Width += colViewColGap
}
bufCol := col.Render(widths[i], height)
buf.ExtendRight(bufCol)
}
return &buf
}
func (w *colView) MaxHeight(width, height int) int {
cols, widths := w.prepareRender(width)
max := 0
for i, col := range cols {
colMax := col.MaxHeight(widths[i], height)
if max < colMax {
max = colMax
}
}
return max
}
// Returns widgets in and widths of columns.
func (w *colView) prepareRender(width int) ([]Widget, []int) {
state := w.CopyState()
ncols := len(state.Columns)
if ncols == 0 {
// No column.
return &term.Buffer{Width: width}
return nil, nil
}
if width < ncols {
// To narrow; give up by rendering nothing.
return &term.Buffer{Width: width}
return nil, nil
}
colWidths := distribute(width-(ncols-1)*colViewColGap, w.Weights(ncols))
var buf term.Buffer
for i, col := range state.Columns {
if i > 0 {
buf.Width += colViewColGap
}
bufCol := col.Render(colWidths[i], height)
buf.ExtendRight(bufCol)
}
return &buf
widths := distribute(width-(ncols-1)*colViewColGap, w.Weights(ncols))
return state.Columns, widths
}
// Handle handles the event first by consulting the overlay handler, and then

View File

@ -53,6 +53,10 @@ func (w *comboBox) Render(width, height int) *term.Buffer {
return buf
}
func (w *comboBox) MaxHeight(width, height int) int {
return w.codeArea.MaxHeight(width, height) + w.listBox.MaxHeight(width, height)
}
// Handle first lets the listbox handle the event, and if it is unhandled, lets
// the codearea handle it. If the codearea has handled the event and the code
// content has changed, it calls OnFilter with the new content.

View File

@ -12,6 +12,11 @@ func (Empty) Render(width, height int) *term.Buffer {
return term.NewBufferBuilder(width).Buffer()
}
// MaxHeight returns 1, since this widget always occupies one line.
func (Empty) MaxHeight(width, height int) int {
return 1
}
// Handle always returns false.
func (Empty) Handle(event term.Event) bool {
return false

View File

@ -13,13 +13,21 @@ type Label struct {
// Render shows the content. If the given box is too small, the text is cropped.
func (l Label) Render(width, height int) *term.Buffer {
// TODO: Optimize by stopping as soon as $height rows are written.
bb := term.NewBufferBuilder(width)
bb.WriteStyled(l.Content)
b := bb.Buffer()
b := l.render(width)
b.TrimToLines(0, height)
return b
}
// MaxHeight returns the maximum height the Label can take when rendering within
// a bound box.
func (l Label) MaxHeight(width, height int) int {
return len(l.render(width).Lines)
}
func (l Label) render(width int) *term.Buffer {
return term.NewBufferBuilder(width).WriteStyled(l.Content).Buffer()
}
// Handle always returns false.
func (l Label) Handle(event term.Event) bool {
return false

View File

@ -85,6 +85,25 @@ func (w *listBox) Render(width, height int) *term.Buffer {
return w.renderVertical(width, height)
}
func (w *listBox) MaxHeight(width, height int) int {
s := w.CopyState()
if s.Items == nil || s.Items.Len() == 0 {
return 0
}
if w.Horizontal {
_, h := getHorizontalWindow(s, w.Padding, width, height)
return h
}
h := 0
for i := 0; i < s.Items.Len(); i++ {
h += s.Items.Show(i).CountLines()
if h >= height {
return height
}
}
return h
}
const listBoxColGap = 2
func (w *listBox) renderHorizontal(width, height int) *term.Buffer {

View File

@ -95,8 +95,8 @@ func getVerticalWindow(state ListBoxState, height int) (first, crop int) {
return 0, 0
}
// Determines the window to show in horizontal It returns the first item
// to show and the amount of height required.
// Determines the window to show in horizontal. Returns the first item to show
// and the amount of height required.
func getHorizontalWindow(state ListBoxState, padding, width, height int) (int, int) {
items := state.Items
n := items.Len()

View File

@ -80,6 +80,10 @@ func (w *textView) Render(width, height int) *term.Buffer {
return buf
}
func (w *textView) MaxHeight(width, height int) int {
return len(w.CopyState().Lines)
}
func (w *textView) getStateForRender(height int) (lines []string, first int) {
w.MutateState(func(s *TextViewState) {
if s.First > len(s.Lines)-height && len(s.Lines)-height >= 0 {

View File

@ -12,15 +12,24 @@ import (
// render itself.
type Widget interface {
Renderer
MaxHeighter
Handler
}
// Renderer wraps the Render method.
type Renderer interface {
// Render onto a region of bound width and height.
// Render renders onto a region of bound width and height.
Render(width, height int) *term.Buffer
}
// MaxHeighter wraps the MaxHeight method.
type MaxHeighter interface {
// MaxHeight returns the maximum height needed when rendering onto a region
// of bound width and height. The returned value may be larger than the
// height argument.
MaxHeight(width, height int) int
}
// Handler wraps the Handle method.
type Handler interface {
// Try to handle a terminal event and returns whether the event has been