From 2eb5f46b01eee431c934c61ec3c4f6bde437ff01 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 30 Nov 2020 22:38:00 -0500 Subject: [PATCH] Make help renderer a little less functional and more structured --- ui/doctypes.go | 2 +- ui/stash.go | 293 -------------------------------------------- ui/stashhelp.go | 320 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+), 294 deletions(-) create mode 100644 ui/stashhelp.go diff --git a/ui/doctypes.go b/ui/doctypes.go index a7dfb0b..a142985 100644 --- a/ui/doctypes.go +++ b/ui/doctypes.go @@ -69,7 +69,7 @@ func (d DocTypeSet) AsSlice() (agg []DocType) { return } -// Return a copy of the given DocumentTypes map. +// Return a copy of the given DoctTypes map. func copyDocumentTypes(d DocTypeSet) DocTypeSet { c := make(map[DocType]struct{}) for k, v := range d { diff --git a/ui/stash.go b/ui/stash.go index d464b22..61abfa6 100644 --- a/ui/stash.go +++ b/ui/stash.go @@ -1105,299 +1105,6 @@ func (m stashModel) populatedView() string { return b.String() } -func (m stashModel) helpView() (string, int) { - numDocs := len(m.getVisibleMarkdowns()) - - if m.filterState == filtering { - var h []string - - switch numDocs { - case 0: - h = []string{"enter/esc", "cancel"} - case 1: - h = []string{"enter", "open", "esc", "cancel"} - default: - h = []string{"enter", "confirm", "esc", "cancel", "ctrl+j/ctrl+k ↑/↓", "choose"} - } - - if m.showFullHelp { - s := m.fullHelpView(h) - l := strings.Count(s, "\n") + 1 - return s, l - } - return m.miniHelpView(h...), 1 - } - - var ( - s string - isStashed, isLocal bool - - navHelp, filterHelp, selectionHelp, sectionHelp, appHelp []string - ) - - if numDocs > 0 { - md := m.selectedMarkdown() - isStashed = md != nil && md.markdownType == StashedDoc - isLocal = md != nil && md.markdownType == LocalDoc - } - - if m.selectionState == selectionSettingNote { - navHelp = append(navHelp, "enter", "confirm", "esc", "cancel") - appHelp = append(appHelp, "q", "quit") - } else if m.selectionState == selectionPromptingDelete { - selectionHelp = append(selectionHelp, "y", "delete", "n", "cancel") - appHelp = append(appHelp, "q", "quit") - } else { - if numDocs > 0 { - navHelp = append(navHelp, "enter", "open", "j/k ↑/↓", "choose") - } - if m.paginator.TotalPages > 1 { - navHelp = append(navHelp, "h/l ←/→", "page") - } - if m.filterState == filterApplied { - filterHelp = append(filterHelp, "/", "edit filter", "esc", "clear filter") - } else { - filterHelp = append(filterHelp, "/", "filter") - } - if isStashed { - selectionHelp = append(selectionHelp, "x", "delete", "m", "set memo") - } else if isLocal && m.online() { - selectionHelp = append(selectionHelp, "s", "stash") - } - if !m.isFiltering() { - if m.docState == stashShowNewsDocs { - sectionHelp = append(sectionHelp, "n", "home") - } else { - sectionHelp = append(sectionHelp, "n", "news") - } - } - if m.err != nil { - appHelp = append(appHelp, "!", "errors") - } - appHelp = append(appHelp, "q", "quit") - } - - if m.showFullHelp { - if m.filterState != filtering && m.selectionState == selectionIdle { - appHelp = append(appHelp, "?", "close help") - } - s = m.fullHelpView(navHelp, filterHelp, selectionHelp, sectionHelp, appHelp) - } else { - if m.filterState != filtering && m.selectionState == selectionIdle { - appHelp = append(appHelp, "?", "more") - } - s = m.miniHelpView(concatStringSlices( - filterHelp, - selectionHelp, - sectionHelp, - appHelp, - )...) - } - - return s, strings.Count(s, "\n") + 1 -} - -// Builds the help view from various sections pieces, truncating it if the view -// would otherwise wrap to two lines. Help view entires should come in as pairs, -// with the first being the key and the second being the help text. -func (m stashModel) miniHelpView(entries ...string) string { - if len(entries) == 0 { - return "" - } - - const truncationWidth = 1 // width of "…" - - var ( - next string - leftGutter = " " - maxWidth = m.general.width - - stashViewHorizontalPadding - - truncationWidth - - ansi.PrintableRuneWidth(leftGutter) - s = leftGutter - ) - - for i := 0; i < len(entries); i = i + 2 { - k := entries[i] - v := entries[i+1] - - switch k { - case "s": - k = greenFg(k) - v = dimGreenFg(v) - default: - k = grayFg(k) - v = midGrayFg(v) - } - - next = fmt.Sprintf("%s %s", k, v) - - if i < len(entries)-2 { - next += dividerDot - } - - // Only this (and the following) help text items if we have the - // horizontal space - if ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth { - s += common.Subtle("…") - break - } - - s += next - } - return s -} - -func (m stashModel) fullHelpView(cols ...[]string) string { - var ( - // Keys and values grouped by column - keys [][]string - vals [][]string - - longestCol int - - // Final rows grouped by column - assembledCols [][]string - ) - - // Get key/value pairs - for _, col := range cols { - if len(col) == 0 { - continue // ignore empty columns - } - - ks, vs := parseHelpTextPairs(col) - keys = append(keys, ks) - vals = append(vals, vs) - } - - // Find the longest column - for _, ks := range keys { - if len(ks) > longestCol { - longestCol = len(ks) - } - } - - // Build columns - for i := range keys { - rows := buildHelpTextColumn(keys[i], vals[i], longestCol) - assembledCols = append(assembledCols, rows) - } - - // Merge columns - return mergeStashHelpColumns(assembledCols...) -} - -// Separate a slice into keys and values. This will panic if it's passed an odd -// number of arguments. -func parseHelpTextPairs(pairs []string) (keys []string, vals []string) { - if len(pairs)%2 != 0 { - panic("help text group must have an even number of items") - } - - for i := 0; i < len(pairs); i = i + 2 { - keys = append(keys, pairs[i]) - vals = append(vals, pairs[i+1]) - } - - return -} - -// Build rows from keys and values. -func buildHelpTextColumn(keys, vals []string, colHeight int) (rows []string) { - if len(keys) != len(vals) { - panic("help text column keys and vals must be of equal lengths") - } - - keyWidth := widestString(keys...) - valWidth := widestString(vals...) - - for i := 0; i < colHeight; i++ { - var ( - b = strings.Builder{} - k, v string - ) - if i < len(keys) { - k = keys[i] - } - if i < len(vals) { - v = vals[i] - } - switch k { - case "s": - k = greenFg(k) - v = dimGreenFg(v) - default: - k = grayFg(k) - v = midGrayFg(v) - } - b.WriteString(k) - b.WriteString(strings.Repeat(" ", keyWidth-ansi.PrintableRuneWidth(k))) // pad keys - b.WriteString(" ") // gap - b.WriteString(v) - b.WriteString(strings.Repeat(" ", valWidth-ansi.PrintableRuneWidth(v))) // pad vals - rows = append(rows, b.String()) - } - - return -} - -// Merge columns together to build the help view. -func mergeStashHelpColumns(cols ...[]string) string { - const minimumHeight = 3 - - var longestCol int - for _, v := range cols { - n := len(v) - if n > longestCol { - longestCol = n - } - } - - if longestCol < minimumHeight { - longestCol = minimumHeight - } - - b := strings.Builder{} - for i := 0; i < longestCol; i++ { - for j, col := range cols { - if i >= len(col) { - // Skip if we're past the length of this column - continue - } - if j == 0 { - b.WriteString(" ") // gutter - } else if j > 0 { - b.WriteString(" ") // gap - } - b.WriteString(col[i]) - } - if i < longestCol-1 { - b.WriteRune('\n') - } - } - - return b.String() -} - -func concatStringSlices(s ...[]string) (agg []string) { - for _, v := range s { - agg = append(agg, v...) - } - return -} - -// Return the cell width of the widest of the given strings. -func widestString(s ...string) (max int) { - for _, v := range s { - n := ansi.PrintableRuneWidth(v) - if n > max { - max = n - } - } - return -} - // COMMANDS func loadRemoteMarkdown(cc *charm.Client, id int, t DocType) tea.Cmd { diff --git a/ui/stashhelp.go b/ui/stashhelp.go new file mode 100644 index 0000000..2ca57dc --- /dev/null +++ b/ui/stashhelp.go @@ -0,0 +1,320 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/charm/ui/common" + "github.com/muesli/reflow/ansi" +) + +// helpEntry is a entry in a help menu containing values for a keystroke and +// it's associated action. +type helpEntry struct{ key, val string } + +// helpColumn is a group of helpEntries which will be rendered into a column. +type helpColumn []helpEntry + +// newHelpColumn creates a help column from pairs of string arguments +// represeting keys and values. If the arguements are not even (and therein +// not every key has a matching value) the function will panic. +func newHelpColumn(pairs ...string) (h helpColumn) { + if len(pairs)%2 != 0 { + panic("help text group must have an even number of items") + } + + for i := 0; i < len(pairs); i = i + 2 { + h = append(h, helpEntry{key: pairs[i], val: pairs[i+1]}) + } + + return +} + +// render returns styled and formatted rows from keys and values. +func (h helpColumn) render(height int) (rows []string) { + keyWidth, valWidth := h.maxWidths() + + for i := 0; i < height; i++ { + var ( + b = strings.Builder{} + k, v string + ) + if i < len(h) { + k = h[i].key + v = h[i].val + + switch k { + case "s": + k = greenFg(k) + v = dimGreenFg(v) + default: + k = grayFg(k) + v = midGrayFg(v) + } + } + b.WriteString(k) + b.WriteString(strings.Repeat(" ", keyWidth-ansi.PrintableRuneWidth(k))) // pad keys + b.WriteString(" ") // gap + b.WriteString(v) + b.WriteString(strings.Repeat(" ", valWidth-ansi.PrintableRuneWidth(v))) // pad vals + rows = append(rows, b.String()) + } + + return +} + +// maxWidths returns the widest key and values in the column, respectively. +func (h helpColumn) maxWidths() (maxKey int, maxVal int) { + for _, v := range h { + kw := ansi.PrintableRuneWidth(v.key) + vw := ansi.PrintableRuneWidth(v.val) + if kw > maxKey { + maxKey = kw + } + if vw > maxVal { + maxVal = vw + } + } + + return +} + +// helpView returns either the mini or full help view depending on the state of +// the model, as well as the total height of the help view. +func (m stashModel) helpView() (string, int) { + numDocs := len(m.getVisibleMarkdowns()) + + // Help for when we're filtering + if m.filterState == filtering { + var h []string + + switch numDocs { + case 0: + h = []string{"enter/esc", "cancel"} + case 1: + h = []string{"enter", "open", "esc", "cancel"} + default: + h = []string{"enter", "confirm", "esc", "cancel", "ctrl+j/ctrl+k ↑/↓", "choose"} + } + + return m.renderHelp(h) + } + + // Help for when we're interacting with a single document + switch m.selectionState { + case selectionSettingNote: + return m.renderHelp([]string{"enter", "confirm", "esc", "cancel"}, []string{"q", "quit"}) + case selectionPromptingDelete: + return m.renderHelp([]string{"y", "delete", "n", "cancel"}, []string{"q", "quit"}) + } + + var ( + isStashed bool + isStashable bool + navHelp []string + filterHelp []string + selectionHelp []string + sectionHelp []string + appHelp []string + ) + + if numDocs > 0 { + md := m.selectedMarkdown() + isStashed = md != nil && md.markdownType == StashedDoc + isStashable = md != nil && md.markdownType == LocalDoc && m.online() + } + + if numDocs > 0 { + navHelp = []string{"enter", "open", "j/k ↑/↓", "choose"} + } + + if m.paginator.TotalPages > 1 { + navHelp = append(navHelp, "h/l ←/→", "page") + } + + // If we're browsing a filtered set + if m.filterState == filterApplied { + filterHelp = []string{"/", "edit filter", "esc", "clear filter"} + } else { + filterHelp = []string{"/", "filter"} + } + + if isStashed { + selectionHelp = []string{"x", "delete", "m", "set memo"} + } else if isStashable { + selectionHelp = []string{"s", "stash"} + } + + // If there's no filtering happening + if !m.isFiltering() { + if m.docState == stashShowNewsDocs { + sectionHelp = []string{"n", "home"} + } else { + sectionHelp = []string{"n", "news"} + } + } + + // If there are errors + if m.err != nil { + appHelp = append(appHelp, "!", "errors") + } + + appHelp = append(appHelp, "q", "quit") + + // Detailed help + if m.showFullHelp { + if m.filterState != filtering { + appHelp = append(appHelp, "?", "close help") + } + return m.renderHelp(navHelp, filterHelp, selectionHelp, sectionHelp, appHelp) + } + + // Mini help + if m.filterState != filtering { + appHelp = append(appHelp, "?", "more") + } + return m.renderHelp(filterHelp, selectionHelp, sectionHelp, appHelp) +} + +// renderHelp returns the rendered help view and associated line height for +// the given groups of help items. +func (m stashModel) renderHelp(groups ...[]string) (string, int) { + if m.showFullHelp { + str := m.fullHelpView(groups...) + numLines := strings.Count(str, "\n") + 1 + return str, numLines + } + return m.miniHelpView(concatStringSlices(groups...)...), 1 +} + +// Builds the help view from various sections pieces, truncating it if the view +// would otherwise wrap to two lines. Help view entires should come in as pairs, +// with the first being the key and the second being the help text. +func (m stashModel) miniHelpView(entries ...string) string { + if len(entries) == 0 { + return "" + } + + var ( + truncationChar = common.Subtle("…") + truncationWidth = ansi.PrintableRuneWidth(truncationChar) + ) + + var ( + next string + leftGutter = " " + maxWidth = m.general.width - + stashViewHorizontalPadding - + truncationWidth - + ansi.PrintableRuneWidth(leftGutter) + s = leftGutter + ) + + for i := 0; i < len(entries); i = i + 2 { + k := entries[i] + v := entries[i+1] + + switch k { + case "s": + k = greenFg(k) + v = dimGreenFg(v) + default: + k = grayFg(k) + v = midGrayFg(v) + } + + next = fmt.Sprintf("%s %s", k, v) + + if i < len(entries)-2 { + next += dividerDot + } + + // Only this (and the following) help text items if we have the + // horizontal space + if ansi.PrintableRuneWidth(s)+ansi.PrintableRuneWidth(next) >= maxWidth { + s += truncationChar + break + } + + s += next + } + return s +} + +func (m stashModel) fullHelpView(groups ...[]string) string { + var ( + columns []helpColumn + tallestCol int + renderedCols [][]string // final rows grouped by column + ) + + // Get key/value pairs + for _, g := range groups { + if len(g) == 0 { + continue // ignore empty columns + } + + columns = append(columns, newHelpColumn(g...)) + } + + // Find the tallest column + for _, c := range columns { + if len(c) > tallestCol { + tallestCol = len(c) + } + } + + // Build columns + for _, c := range columns { + renderedCols = append(renderedCols, c.render(tallestCol)) + } + + // Merge columns + return mergeColumns(renderedCols...) +} + +// Merge columns together to build the help view. +func mergeColumns(cols ...[]string) string { + const minimumHeight = 3 + + // Find the tallest column + var tallestCol int + for _, v := range cols { + n := len(v) + if n > tallestCol { + tallestCol = n + } + } + + // Make sure the tallest column meets the minimum height + if tallestCol < minimumHeight { + tallestCol = minimumHeight + } + + b := strings.Builder{} + for i := 0; i < tallestCol; i++ { + for j, col := range cols { + if i >= len(col) { + continue // skip if we're past the length of this column + } + if j == 0 { + b.WriteString(" ") // gutter + } else if j > 0 { + b.WriteString(" ") // gap + } + b.WriteString(col[i]) + } + if i < tallestCol-1 { + b.WriteRune('\n') + } + } + + return b.String() +} + +func concatStringSlices(s ...[]string) (agg []string) { + for _, v := range s { + agg = append(agg, v...) + } + return +}