vikunja/veans/internal/picker/model.go

239 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package picker
import (
"fmt"
"strings"
"code.vikunja.io/veans/internal/client"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const maxVisibleRows = 12
var (
dimStyle = lipgloss.NewStyle().Faint(true)
matchStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
cursorMark = ""
)
// model is the bubbletea state for the picker. The pinned "create a new
// project" entry is the trailing row with a nil project; it is always
// selectable and never filtered out.
type model struct {
forest []*node
query string
rows []row
cursor int // index into rows, always on a selectable row
offset int // first visible row index
result *client.Project
createNew bool
canceled bool
}
func newModel(forest []*node) *model {
m := &model{forest: forest}
m.recompute()
return m
}
func (m *model) recompute() {
rows := flatten(m.forest, m.query)
rows = append(rows, row{project: nil}) // pinned create row
m.rows = rows
// recompute only runs when the query changes (or on init), so snap to the
// first match. Keeping the old cursor could leave it on the trailing create
// row after the list narrows, making Enter create a project instead of
// picking the visible match.
m.cursor = 0
m.offset = 0
m.clampCursor()
m.ensureVisible()
}
func (r row) isCreate() bool { return r.project == nil }
func (r row) selectable() bool { return r.isCreate() || !r.dimmed }
func (m *model) clampCursor() {
if m.cursor >= len(m.rows) {
m.cursor = len(m.rows) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
if m.rows[m.cursor].selectable() {
return
}
// Snap to the nearest selectable row, preferring downward.
for i := m.cursor; i < len(m.rows); i++ {
if m.rows[i].selectable() {
m.cursor = i
return
}
}
for i := m.cursor; i >= 0; i-- {
if m.rows[i].selectable() {
m.cursor = i
return
}
}
}
func (m *model) moveCursor(delta int) {
i := m.cursor
for {
i += delta
if i < 0 || i >= len(m.rows) {
return
}
if m.rows[i].selectable() {
m.cursor = i
m.ensureVisible()
return
}
}
}
func (m *model) ensureVisible() {
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+maxVisibleRows {
m.offset = m.cursor - maxVisibleRows + 1
}
if m.offset < 0 {
m.offset = 0
}
}
func (m *model) Init() tea.Cmd { return nil }
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch key.String() {
case "ctrl+c", "esc":
m.canceled = true
return m, tea.Quit
case "enter":
sel := m.rows[m.cursor]
if sel.isCreate() {
m.createNew = true
} else {
m.result = sel.project
}
return m, tea.Quit
case "up":
m.moveCursor(-1)
case "down":
m.moveCursor(1)
case "backspace":
if m.query != "" {
r := []rune(m.query)
m.query = string(r[:len(r)-1])
m.recompute()
}
default:
// Treat printable runes and space as query input.
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
runes := key.Runes
// KeySpace is not guaranteed to populate key.Runes; substitute a
// literal space so multi-word fuzzy queries still work.
if key.Type == tea.KeySpace && len(runes) == 0 {
runes = []rune{' '}
}
m.query += string(runes)
m.recompute()
}
}
return m, nil
}
func (m *model) View() string {
var b strings.Builder
fmt.Fprintf(&b, "> %s\n", m.query)
end := min(m.offset+maxVisibleRows, len(m.rows))
for i := m.offset; i < end; i++ {
b.WriteString(m.renderRow(i))
b.WriteByte('\n')
}
fmt.Fprintf(&b, "%d/%d ↑↓ move ⏎ pick esc cancel\n", m.cursor+1, len(m.rows))
return b.String()
}
func (m *model) renderRow(i int) string {
r := m.rows[i]
marker := " "
if i == m.cursor {
marker = cursorMark + " "
}
indent := strings.Repeat(" ", r.depth)
var label string
switch {
case r.isCreate():
label = "Create a new project"
case r.dimmed:
label = dimStyle.Render(r.project.Title + projectSuffix(r.project))
default:
label = highlight(r.project.Title, r.matches) + dimStyle.Render(projectSuffix(r.project))
}
return marker + indent + label
}
// projectSuffix is the dimmed metadata appended to a project row. Titles aren't
// unique in Vikunja, so the id (and identifier when set) keeps duplicate-titled
// projects distinguishable during init.
func projectSuffix(p *client.Project) string {
s := fmt.Sprintf(" #%d", p.ID)
if p.Identifier != "" {
s += " " + p.Identifier
}
return s
}
// highlight bolds the matched runes of title. matches are rune indexes.
func highlight(title string, matches []int) string {
if len(matches) == 0 {
return title
}
matchSet := make(map[int]bool, len(matches))
for _, idx := range matches {
matchSet[idx] = true
}
var b strings.Builder
for i, r := range []rune(title) {
if matchSet[i] {
b.WriteString(matchStyle.Render(string(r)))
} else {
b.WriteRune(r)
}
}
return b.String()
}