From beb907d59eaf82d9bac8bb3066aef193fb47f72d Mon Sep 17 00:00:00 2001 From: David Dworken Date: Sun, 24 Sep 2023 15:57:39 -0700 Subject: [PATCH] Add bolding of matching search results for #112, currently behind the beta-mode flag --- client/lib/lib.go | 26 ++++++++++++----- client/table/table.go | 49 ++++++++++++++++++++++++++++++-- client/tui/tui.go | 66 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 10 deletions(-) diff --git a/client/lib/lib.go b/client/lib/lib.go index 3721c58..1135f84 100644 --- a/client/lib/lib.go +++ b/client/lib/lib.go @@ -170,6 +170,21 @@ func BuildTableRow(ctx context.Context, columnNames []string, entry data.History return row, nil } +// Make a regex that matches the non-tokenized bits of the given query +func MakeRegexFromQuery(query string) string { + tokens := tokenize(strings.TrimSpace(query)) + r := "" + for _, token := range tokens { + if !strings.HasPrefix(token, "-") && !containsUnescaped(token, ":") { + if r != "" { + r += "|" + } + r += fmt.Sprintf("(%s)", regexp.QuoteMeta(token)) + } + } + return r +} + func stringArrayToAnyArray(arr []string) []any { ret := make([]any, 0) for _, item := range arr { @@ -711,10 +726,7 @@ func where(tx *gorm.DB, s string, v1 any, v2 any) *gorm.DB { } func MakeWhereQueryFromSearch(ctx context.Context, db *gorm.DB, query string) (*gorm.DB, error) { - tokens, err := tokenize(query) - if err != nil { - return nil, fmt.Errorf("failed to tokenize query: %w", err) - } + tokens := tokenize(query) tx := db.Model(&data.HistoryEntry{}).Where("true") for _, token := range tokens { if strings.HasPrefix(token, "-") { @@ -891,11 +903,11 @@ func getAllCustomColumnNames(ctx context.Context) ([]string, error) { return ccNames, nil } -func tokenize(query string) ([]string, error) { +func tokenize(query string) []string { if query == "" { - return []string{}, nil + return []string{} } - return splitEscaped(query, ' ', -1), nil + return splitEscaped(query, ' ', -1) } func splitEscaped(query string, separator rune, maxSplit int) []string { diff --git a/client/table/table.go b/client/table/table.go index 06fa12e..5d3579f 100644 --- a/client/table/table.go +++ b/client/table/table.go @@ -1,4 +1,5 @@ // Forked from https://github.com/charmbracelet/bubbles/blob/master/table/table.go to add horizontal scrolling +// Also includes https://github.com/charmbracelet/bubbles/pull/397/files to support cell styling package table @@ -31,6 +32,13 @@ type Model struct { hcursor int } +// CellPosition holds row and column indexes. +type CellPosition struct { + RowID int + Column int + IsRowSelected bool +} + // Row represents one line in the table. type Row []string @@ -108,6 +116,32 @@ type Styles struct { Header lipgloss.Style Cell lipgloss.Style Selected lipgloss.Style + + // RenderCell is a low-level primitive for stylizing cells. + // It is responsible for rendering the selection style. Styles.Cell is ignored. + // + // Example implementation: + // s.RenderCell = func(model table.Model, value string, position table.CellPosition) string { + // cellStyle := s.Cell.Copy() + // + // switch { + // case position.IsRowSelected: + // return cellStyle.Background(lipgloss.Color("57")).Render(value) + // case position.Column == 1: + // return cellStyle.Foreground(lipgloss.Color("21")).Render(value) + // default: + // return cellStyle.Render(value) + // } + // } + RenderCell func(model Model, value string, position CellPosition) string +} + +func (s Styles) renderCell(model Model, value string, position CellPosition) string { + if s.RenderCell != nil { + return s.RenderCell(model, value, position) + } + + return s.Cell.Render(value) } // DefaultStyles returns a set of default style definitions for this table. @@ -445,21 +479,30 @@ func (m Model) headersView() string { } func (m *Model) renderRow(rowID int) string { + isRowSelected := rowID == m.cursor var s = make([]string, 0, len(m.cols)) for i, value := range m.rows[rowID] { style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) + + position := CellPosition{ + RowID: rowID, + Column: i, + IsRowSelected: isRowSelected, + } + var renderedCell string if i == m.ColIndex(m.hcol) && m.hcursor > 0 { - renderedCell = m.styles.Cell.Render(style.Render(runewidth.Truncate(runewidth.TruncateLeft(value, m.hcursor, "…"), m.cols[i].Width, "…"))) + renderedCell = style.Render(runewidth.Truncate(runewidth.TruncateLeft(value, m.hcursor, "…"), m.cols[i].Width, "…")) } else { - renderedCell = m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.cols[i].Width, "…"))) + renderedCell = style.Render(runewidth.Truncate(value, m.cols[i].Width, "…")) } + renderedCell = m.styles.renderCell(*m, renderedCell, position) s = append(s, renderedCell) } row := lipgloss.JoinHorizontal(lipgloss.Left, s...) - if rowID == m.cursor { + if isRowSelected { return m.styles.Selected.Render(row) } diff --git a/client/tui/tui.go b/client/tui/tui.go index 443d9f4..3eea3c7 100644 --- a/client/tui/tui.go +++ b/client/tui/tui.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "regexp" "strings" "time" @@ -27,6 +28,7 @@ import ( const TABLE_HEIGHT = 20 const PADDED_NUM_ENTRIES = TABLE_HEIGHT * 5 +var CURRENT_QUERY_FOR_HIGHLIGHTING string = "" var SELECTED_COMMAND string = "" var baseStyle = lipgloss.NewStyle(). @@ -207,6 +209,7 @@ func initialModel(ctx context.Context, initialQuery string) model { if initialQuery != "" { queryInput.SetValue(initialQuery) } + CURRENT_QUERY_FOR_HIGHLIGHTING = initialQuery return model{ctx: ctx, spinner: s, isLoading: true, table: nil, tableEntries: []*data.HistoryEntry{}, runQuery: &initialQuery, queryInput: queryInput, help: help.New()} } @@ -319,6 +322,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.queryInput = i searchQuery := m.queryInput.Value() m.runQuery = &searchQuery + CURRENT_QUERY_FOR_HIGHLIGHTING = searchQuery cmd3 := runQueryAndUpdateTable(m, false, false) preventTableOverscrolling(m) return m, tea.Batch(pendingCommands, cmd2, cmd3) @@ -579,6 +583,68 @@ func makeTable(ctx context.Context, rows []table.Row) (table.Model, error) { Foreground(lipgloss.Color("229")). Background(lipgloss.Color("57")). Bold(false) + if config.BetaMode { + s.RenderCell = func(model table.Model, value string, position table.CellPosition) string { + MATCH_NOTHING_REGEXP := regexp.MustCompile("a^") + var re *regexp.Regexp + CURRENT_QUERY_FOR_HIGHLIGHTING = strings.TrimSpace(CURRENT_QUERY_FOR_HIGHLIGHTING) + if CURRENT_QUERY_FOR_HIGHLIGHTING == "" { + re = MATCH_NOTHING_REGEXP + } else { + queryRegex := lib.MakeRegexFromQuery(CURRENT_QUERY_FOR_HIGHLIGHTING) + r, err := regexp.Compile(queryRegex) + if err != nil { + // Failed to compile the regex for highlighting matches, this should never happen. In this + // case, just use a regexp that matches nothing to ensure that the TUI doesn't crash. + re = MATCH_NOTHING_REGEXP + } else { + re = r + } + } + + renderChunk := func(v string, isMatching, isLeftMost, isRightMost bool) string { + hctx.GetLogger().Infof("Rendering chunk=%#v with isMatching=%#v", v, isMatching) + baseStyle := lipgloss.NewStyle() + if position.IsRowSelected { + baseStyle = s.Selected.Copy() + } + if isLeftMost { + baseStyle = baseStyle.PaddingLeft(1) + } + if isRightMost { + baseStyle = baseStyle.PaddingRight(1) + } + if isMatching { + baseStyle = baseStyle.Bold(true) + } + return baseStyle.Render(v) + } + + matches := re.FindAllStringIndex(value, -1) + if len(matches) == 0 { + return renderChunk(value, false, true, true) + } + + ret := "" + lastIncludedIdx := 0 + for _, match := range re.FindAllStringIndex(value, -1) { + matchStartIdx := match[0] + matchEndIdx := match[1] + beforeMatch := value[lastIncludedIdx:matchStartIdx] + if beforeMatch != "" { + ret += renderChunk(beforeMatch, false, lastIncludedIdx == 0, false) + } + match := value[matchStartIdx:matchEndIdx] + ret += renderChunk(match, true, matchStartIdx == 0, matchEndIdx == len(value)) + lastIncludedIdx = matchEndIdx + } + if lastIncludedIdx != len(value) { + ret += renderChunk(value[lastIncludedIdx:], false, false, true) + } + hctx.GetLogger().Infof("bolded=%#v, original=%#v", ret, s.Cell.Render(value)) + return ret + } + } t.SetStyles(s) t.Focus() return t, nil