Compare commits
10 Commits
035d09900e
...
82c41a22d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 82c41a22d2 | |||
| 73d51b956a | |||
| fac7ff81dd | |||
| 0d55a3b119 | |||
| c660b6cbb1 | |||
| 98d2d041d6 | |||
| bafd8958d4 | |||
| 3e1cb9d1bc | |||
| 0572763e31 | |||
| 9aa7b04b98 |
@ -15,6 +15,7 @@ type Common struct {
|
|||||||
TW taskwarrior.TaskWarrior
|
TW taskwarrior.TaskWarrior
|
||||||
Keymap *Keymap
|
Keymap *Keymap
|
||||||
Styles *Styles
|
Styles *Styles
|
||||||
|
Udas []taskwarrior.Uda
|
||||||
|
|
||||||
pageStack *Stack[Component]
|
pageStack *Stack[Component]
|
||||||
width int
|
width int
|
||||||
@ -27,6 +28,7 @@ func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common {
|
|||||||
TW: tw,
|
TW: tw,
|
||||||
Keymap: NewKeymap(),
|
Keymap: NewKeymap(),
|
||||||
Styles: NewStyles(tw.GetConfig()),
|
Styles: NewStyles(tw.GetConfig()),
|
||||||
|
Udas: tw.GetUdas(),
|
||||||
|
|
||||||
pageStack: NewStack[Component](),
|
pageStack: NewStack[Component](),
|
||||||
}
|
}
|
||||||
@ -52,5 +54,11 @@ func (c *Common) PushPage(page Component) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Common) PopPage() (Component, error) {
|
func (c *Common) PopPage() (Component, error) {
|
||||||
return c.pageStack.Pop()
|
component, err := c.pageStack.Pop()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
component.SetSize(c.width, c.height)
|
||||||
|
return component, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,10 @@ type Keymap struct {
|
|||||||
Down key.Binding
|
Down key.Binding
|
||||||
Left key.Binding
|
Left key.Binding
|
||||||
Right key.Binding
|
Right key.Binding
|
||||||
|
Next key.Binding
|
||||||
|
Prev key.Binding
|
||||||
|
NextPage key.Binding
|
||||||
|
PrevPage key.Binding
|
||||||
SetReport key.Binding
|
SetReport key.Binding
|
||||||
SetContext key.Binding
|
SetContext key.Binding
|
||||||
SetProject key.Binding
|
SetProject key.Binding
|
||||||
@ -86,6 +90,26 @@ func NewKeymap() *Keymap {
|
|||||||
key.WithHelp("→/l", "Right"),
|
key.WithHelp("→/l", "Right"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Next: key.NewBinding(
|
||||||
|
key.WithKeys("tab"),
|
||||||
|
key.WithHelp("tab", "Next"),
|
||||||
|
),
|
||||||
|
|
||||||
|
Prev: key.NewBinding(
|
||||||
|
key.WithKeys("shift+tab"),
|
||||||
|
key.WithHelp("shift+tab", "Previous"),
|
||||||
|
),
|
||||||
|
|
||||||
|
NextPage: key.NewBinding(
|
||||||
|
key.WithKeys("]"),
|
||||||
|
key.WithHelp("[", "Next page"),
|
||||||
|
),
|
||||||
|
|
||||||
|
PrevPage: key.NewBinding(
|
||||||
|
key.WithKeys("["),
|
||||||
|
key.WithHelp("]", "Previous page"),
|
||||||
|
),
|
||||||
|
|
||||||
SetReport: key.NewBinding(
|
SetReport: key.NewBinding(
|
||||||
key.WithKeys("r"),
|
key.WithKeys("r"),
|
||||||
key.WithHelp("r", "Set report"),
|
key.WithHelp("r", "Set report"),
|
||||||
@ -102,8 +126,8 @@ func NewKeymap() *Keymap {
|
|||||||
),
|
),
|
||||||
|
|
||||||
Select: key.NewBinding(
|
Select: key.NewBinding(
|
||||||
key.WithKeys("enter"),
|
key.WithKeys(" "),
|
||||||
key.WithHelp("enter", "Select"),
|
key.WithHelp("space", "Select"),
|
||||||
),
|
),
|
||||||
|
|
||||||
Insert: key.NewBinding(
|
Insert: key.NewBinding(
|
||||||
|
|||||||
194
common/styles.go
194
common/styles.go
@ -20,6 +20,8 @@ type TableStyle struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Styles struct {
|
type Styles struct {
|
||||||
|
Colors map[string]*lipgloss.Style
|
||||||
|
|
||||||
Base lipgloss.Style
|
Base lipgloss.Style
|
||||||
|
|
||||||
Form *huh.Theme
|
Form *huh.Theme
|
||||||
@ -28,59 +30,22 @@ type Styles struct {
|
|||||||
ColumnFocused lipgloss.Style
|
ColumnFocused lipgloss.Style
|
||||||
ColumnBlurred lipgloss.Style
|
ColumnBlurred lipgloss.Style
|
||||||
ColumnInsert lipgloss.Style
|
ColumnInsert lipgloss.Style
|
||||||
|
|
||||||
// TODO: make color config completely dynamic to account for keyword., project., tag. and uda. colors
|
|
||||||
Active lipgloss.Style
|
|
||||||
Alternate lipgloss.Style
|
|
||||||
Blocked lipgloss.Style
|
|
||||||
Blocking lipgloss.Style
|
|
||||||
BurndownDone lipgloss.Style
|
|
||||||
BurndownPending lipgloss.Style
|
|
||||||
BurndownStarted lipgloss.Style
|
|
||||||
CalendarDue lipgloss.Style
|
|
||||||
CalendarDueToday lipgloss.Style
|
|
||||||
CalendarHoliday lipgloss.Style
|
|
||||||
CalendarOverdue lipgloss.Style
|
|
||||||
CalendarScheduled lipgloss.Style
|
|
||||||
CalendarToday lipgloss.Style
|
|
||||||
CalendarWeekend lipgloss.Style
|
|
||||||
CalendarWeeknumber lipgloss.Style
|
|
||||||
Completed lipgloss.Style
|
|
||||||
Debug lipgloss.Style
|
|
||||||
Deleted lipgloss.Style
|
|
||||||
Due lipgloss.Style
|
|
||||||
DueToday lipgloss.Style
|
|
||||||
Error lipgloss.Style
|
|
||||||
Footnote lipgloss.Style
|
|
||||||
Header lipgloss.Style
|
|
||||||
HistoryAdd lipgloss.Style
|
|
||||||
HistoryDelete lipgloss.Style
|
|
||||||
HistoryDone lipgloss.Style
|
|
||||||
Label lipgloss.Style
|
|
||||||
LabelSort lipgloss.Style
|
|
||||||
Overdue lipgloss.Style
|
|
||||||
ProjectNone lipgloss.Style
|
|
||||||
Recurring lipgloss.Style
|
|
||||||
Scheduled lipgloss.Style
|
|
||||||
SummaryBackground lipgloss.Style
|
|
||||||
SummaryBar lipgloss.Style
|
|
||||||
SyncAdded lipgloss.Style
|
|
||||||
SyncChanged lipgloss.Style
|
|
||||||
SyncRejected lipgloss.Style
|
|
||||||
TagNext lipgloss.Style
|
|
||||||
TagNone lipgloss.Style
|
|
||||||
Tagged lipgloss.Style
|
|
||||||
UdaPriorityH lipgloss.Style
|
|
||||||
UdaPriorityL lipgloss.Style
|
|
||||||
UdaPriorityM lipgloss.Style
|
|
||||||
UndoAfter lipgloss.Style
|
|
||||||
UndoBefore lipgloss.Style
|
|
||||||
Until lipgloss.Style
|
|
||||||
Warning lipgloss.Style
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
||||||
styles := parseColors(config.GetConfig())
|
styles := Styles{}
|
||||||
|
|
||||||
|
colors := make(map[string]*lipgloss.Style)
|
||||||
|
|
||||||
|
for key, value := range config.GetConfig() {
|
||||||
|
if strings.HasPrefix(key, "color.") {
|
||||||
|
_, color, _ := strings.Cut(key, ".")
|
||||||
|
colors[color] = parseColorString(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
styles.Colors = colors
|
||||||
|
|
||||||
styles.Base = lipgloss.NewStyle()
|
styles.Base = lipgloss.NewStyle()
|
||||||
|
|
||||||
styles.TableStyle = TableStyle{
|
styles.TableStyle = TableStyle{
|
||||||
@ -92,11 +57,12 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|||||||
|
|
||||||
formTheme := huh.ThemeBase()
|
formTheme := huh.ThemeBase()
|
||||||
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
|
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
|
||||||
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ")
|
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ")
|
||||||
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
|
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
|
||||||
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("> ")
|
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ")
|
||||||
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
|
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
|
||||||
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
||||||
|
formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true)
|
||||||
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
||||||
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
||||||
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
||||||
@ -105,127 +71,23 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|||||||
|
|
||||||
styles.Form = formTheme
|
styles.Form = formTheme
|
||||||
|
|
||||||
styles.ColumnFocused = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true)
|
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||||
styles.ColumnBlurred = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.HiddenBorder(), true)
|
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
||||||
styles.ColumnInsert = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true).BorderForeground(styles.Active.GetForeground())
|
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||||
|
if styles.Colors["active"] != nil {
|
||||||
return styles
|
styles.ColumnInsert = styles.ColumnInsert.BorderForeground(styles.Colors["active"].GetForeground())
|
||||||
}
|
|
||||||
|
|
||||||
func parseColors(config map[string]string) *Styles {
|
|
||||||
styles := Styles{}
|
|
||||||
|
|
||||||
for key, value := range config {
|
|
||||||
if strings.HasPrefix(key, "color.") {
|
|
||||||
_, colorValue, _ := strings.Cut(key, ".")
|
|
||||||
switch colorValue {
|
|
||||||
case "active":
|
|
||||||
styles.Active = parseColorString(value)
|
|
||||||
case "alternate":
|
|
||||||
styles.Alternate = parseColorString(value)
|
|
||||||
case "blocked":
|
|
||||||
styles.Blocked = parseColorString(value)
|
|
||||||
case "blocking":
|
|
||||||
styles.Blocking = parseColorString(value)
|
|
||||||
case "burndown.done":
|
|
||||||
styles.BurndownDone = parseColorString(value)
|
|
||||||
case "burndown.pending":
|
|
||||||
styles.BurndownPending = parseColorString(value)
|
|
||||||
case "burndown.started":
|
|
||||||
styles.BurndownStarted = parseColorString(value)
|
|
||||||
case "calendar.due":
|
|
||||||
styles.CalendarDue = parseColorString(value)
|
|
||||||
case "calendar.due.today":
|
|
||||||
styles.CalendarDueToday = parseColorString(value)
|
|
||||||
case "calendar.holiday":
|
|
||||||
styles.CalendarHoliday = parseColorString(value)
|
|
||||||
case "calendar.overdue":
|
|
||||||
styles.CalendarOverdue = parseColorString(value)
|
|
||||||
case "calendar.scheduled":
|
|
||||||
styles.CalendarScheduled = parseColorString(value)
|
|
||||||
case "calendar.today":
|
|
||||||
styles.CalendarToday = parseColorString(value)
|
|
||||||
case "calendar.weekend":
|
|
||||||
styles.CalendarWeekend = parseColorString(value)
|
|
||||||
case "calendar.weeknumber":
|
|
||||||
styles.CalendarWeeknumber = parseColorString(value)
|
|
||||||
case "completed":
|
|
||||||
styles.Completed = parseColorString(value)
|
|
||||||
case "debug":
|
|
||||||
styles.Debug = parseColorString(value)
|
|
||||||
case "deleted":
|
|
||||||
styles.Deleted = parseColorString(value)
|
|
||||||
case "due":
|
|
||||||
styles.Due = parseColorString(value)
|
|
||||||
case "due.today":
|
|
||||||
styles.DueToday = parseColorString(value)
|
|
||||||
case "error":
|
|
||||||
styles.Error = parseColorString(value)
|
|
||||||
case "footnote":
|
|
||||||
styles.Footnote = parseColorString(value)
|
|
||||||
case "header":
|
|
||||||
styles.Header = parseColorString(value)
|
|
||||||
case "history.add":
|
|
||||||
styles.HistoryAdd = parseColorString(value)
|
|
||||||
case "history.delete":
|
|
||||||
styles.HistoryDelete = parseColorString(value)
|
|
||||||
case "history.done":
|
|
||||||
styles.HistoryDone = parseColorString(value)
|
|
||||||
case "label":
|
|
||||||
styles.Label = parseColorString(value)
|
|
||||||
case "label.sort":
|
|
||||||
styles.LabelSort = parseColorString(value)
|
|
||||||
case "overdue":
|
|
||||||
styles.Overdue = parseColorString(value)
|
|
||||||
case "project.none":
|
|
||||||
styles.ProjectNone = parseColorString(value)
|
|
||||||
case "recurring":
|
|
||||||
styles.Recurring = parseColorString(value)
|
|
||||||
case "scheduled":
|
|
||||||
styles.Scheduled = parseColorString(value)
|
|
||||||
case "summary.background":
|
|
||||||
styles.SummaryBackground = parseColorString(value)
|
|
||||||
case "summary.bar":
|
|
||||||
styles.SummaryBar = parseColorString(value)
|
|
||||||
case "sync.added":
|
|
||||||
styles.SyncAdded = parseColorString(value)
|
|
||||||
case "sync.changed":
|
|
||||||
styles.SyncChanged = parseColorString(value)
|
|
||||||
case "sync.rejected":
|
|
||||||
styles.SyncRejected = parseColorString(value)
|
|
||||||
case "tag.next":
|
|
||||||
styles.TagNext = parseColorString(value)
|
|
||||||
case "tag.none":
|
|
||||||
styles.TagNone = parseColorString(value)
|
|
||||||
case "tagged":
|
|
||||||
styles.Tagged = parseColorString(value)
|
|
||||||
case "uda.priority.H":
|
|
||||||
styles.UdaPriorityH = parseColorString(value)
|
|
||||||
case "uda.priority.L":
|
|
||||||
styles.UdaPriorityL = parseColorString(value)
|
|
||||||
case "uda.priority.M":
|
|
||||||
styles.UdaPriorityM = parseColorString(value)
|
|
||||||
case "undo.after":
|
|
||||||
styles.UndoAfter = parseColorString(value)
|
|
||||||
case "undo.before":
|
|
||||||
styles.UndoBefore = parseColorString(value)
|
|
||||||
case "until":
|
|
||||||
styles.Until = parseColorString(value)
|
|
||||||
case "warning":
|
|
||||||
styles.Warning = parseColorString(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &styles
|
return &styles
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseColorString(color string) lipgloss.Style {
|
func parseColorString(color string) *lipgloss.Style {
|
||||||
style := lipgloss.NewStyle()
|
|
||||||
if color == "" {
|
if color == "" {
|
||||||
return style
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle()
|
||||||
|
|
||||||
if strings.Contains(color, "on") {
|
if strings.Contains(color, "on") {
|
||||||
fgbg := strings.Split(color, "on")
|
fgbg := strings.Split(color, "on")
|
||||||
fg := strings.TrimSpace(fgbg[0])
|
fg := strings.TrimSpace(fgbg[0])
|
||||||
@ -240,7 +102,7 @@ func parseColorString(color string) lipgloss.Style {
|
|||||||
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return style
|
return &style
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseColor(color string) lipgloss.Color {
|
func parseColor(color string) lipgloss.Color {
|
||||||
|
|||||||
667
components/input/multiselect.go
Normal file
667
components/input/multiselect.go
Normal file
@ -0,0 +1,667 @@
|
|||||||
|
package input
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"tasksquire/common"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/huh/accessibility"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MultiSelect is a form multi-select field.
|
||||||
|
type MultiSelect struct {
|
||||||
|
common *common.Common
|
||||||
|
|
||||||
|
value *[]string
|
||||||
|
key string
|
||||||
|
|
||||||
|
// customization
|
||||||
|
title string
|
||||||
|
description string
|
||||||
|
options []Option[string]
|
||||||
|
filterable bool
|
||||||
|
filteredOptions []Option[string]
|
||||||
|
limit int
|
||||||
|
height int
|
||||||
|
|
||||||
|
// error handling
|
||||||
|
validate func([]string) error
|
||||||
|
err error
|
||||||
|
|
||||||
|
// state
|
||||||
|
cursor int
|
||||||
|
focused bool
|
||||||
|
filtering bool
|
||||||
|
filter textinput.Model
|
||||||
|
viewport viewport.Model
|
||||||
|
|
||||||
|
// options
|
||||||
|
width int
|
||||||
|
accessible bool
|
||||||
|
theme *huh.Theme
|
||||||
|
keymap huh.MultiSelectKeyMap
|
||||||
|
|
||||||
|
// new
|
||||||
|
hasNewOption bool
|
||||||
|
newInput textinput.Model
|
||||||
|
newInputActive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMultiSelect returns a new multi-select field.
|
||||||
|
func NewMultiSelect(common *common.Common) *MultiSelect {
|
||||||
|
filter := textinput.New()
|
||||||
|
filter.Prompt = "/"
|
||||||
|
|
||||||
|
newInput := textinput.New()
|
||||||
|
newInput.Prompt = "New: "
|
||||||
|
|
||||||
|
return &MultiSelect{
|
||||||
|
common: common,
|
||||||
|
options: []Option[string]{},
|
||||||
|
value: new([]string),
|
||||||
|
validate: func([]string) error { return nil },
|
||||||
|
filtering: false,
|
||||||
|
filter: filter,
|
||||||
|
newInput: newInput,
|
||||||
|
newInputActive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value sets the value of the multi-select field.
|
||||||
|
func (m *MultiSelect) Value(value *[]string) *MultiSelect {
|
||||||
|
m.value = value
|
||||||
|
for i, o := range m.options {
|
||||||
|
for _, v := range *value {
|
||||||
|
if o.Value == v {
|
||||||
|
m.options[i].selected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key sets the key of the select field which can be used to retrieve the value
|
||||||
|
// after submission.
|
||||||
|
func (m *MultiSelect) Key(key string) *MultiSelect {
|
||||||
|
m.key = key
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title sets the title of the multi-select field.
|
||||||
|
func (m *MultiSelect) Title(title string) *MultiSelect {
|
||||||
|
m.title = title
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description sets the description of the multi-select field.
|
||||||
|
func (m *MultiSelect) Description(description string) *MultiSelect {
|
||||||
|
m.description = description
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options sets the options of the multi-select field.
|
||||||
|
func (m *MultiSelect) Options(hasNewOption bool, options ...Option[string]) *MultiSelect {
|
||||||
|
m.hasNewOption = hasNewOption
|
||||||
|
|
||||||
|
if m.hasNewOption {
|
||||||
|
newOption := []Option[string]{
|
||||||
|
{Key: "(new)", Value: ""},
|
||||||
|
}
|
||||||
|
options = append(newOption, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(options) <= 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, o := range options {
|
||||||
|
for _, v := range *m.value {
|
||||||
|
if o.Value == v {
|
||||||
|
options[i].selected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.options = options
|
||||||
|
m.filteredOptions = options
|
||||||
|
m.updateViewportHeight()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filterable sets the multi-select field as filterable.
|
||||||
|
func (m *MultiSelect) Filterable(filterable bool) *MultiSelect {
|
||||||
|
m.filterable = filterable
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit sets the limit of the multi-select field.
|
||||||
|
func (m *MultiSelect) Limit(limit int) *MultiSelect {
|
||||||
|
m.limit = limit
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height sets the height of the multi-select field.
|
||||||
|
func (m *MultiSelect) Height(height int) *MultiSelect {
|
||||||
|
// What we really want to do is set the height of the viewport, but we
|
||||||
|
// need a theme applied before we can calcualate its height.
|
||||||
|
m.height = height
|
||||||
|
m.updateViewportHeight()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sets the validation function of the multi-select field.
|
||||||
|
func (m *MultiSelect) Validate(validate func([]string) error) *MultiSelect {
|
||||||
|
m.validate = validate
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the error of the multi-select field.
|
||||||
|
func (m *MultiSelect) Error() error {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip returns whether the multiselect should be skipped or should be blocking.
|
||||||
|
func (*MultiSelect) Skip() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom returns whether the multiselect should be zoomed.
|
||||||
|
func (*MultiSelect) Zoom() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the multi-select field.
|
||||||
|
func (m *MultiSelect) Focus() tea.Cmd {
|
||||||
|
m.focused = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the multi-select field.
|
||||||
|
func (m *MultiSelect) Blur() tea.Cmd {
|
||||||
|
m.focused = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyBinds returns the help message for the multi-select field.
|
||||||
|
func (m *MultiSelect) KeyBinds() []key.Binding {
|
||||||
|
return []key.Binding{
|
||||||
|
m.keymap.Toggle,
|
||||||
|
m.keymap.Up,
|
||||||
|
m.keymap.Down,
|
||||||
|
m.keymap.Filter,
|
||||||
|
m.keymap.SetFilter,
|
||||||
|
m.keymap.ClearFilter,
|
||||||
|
m.keymap.Prev,
|
||||||
|
m.keymap.Submit,
|
||||||
|
m.keymap.Next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the multi-select field.
|
||||||
|
func (m *MultiSelect) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates the multi-select field.
|
||||||
|
func (m *MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
// Enforce height on the viewport during update as we need themes to
|
||||||
|
// be applied before we can calculate the height.
|
||||||
|
m.updateViewportHeight()
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if m.filtering {
|
||||||
|
m.filter, cmd = m.filter.Update(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.newInputActive {
|
||||||
|
m.newInput, cmd = m.newInput.Update(msg)
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.common.Keymap.Ok):
|
||||||
|
newOptions := []Option[string]{}
|
||||||
|
for _, item := range strings.Split(m.newInput.Value(), " ") {
|
||||||
|
newOptions = append(newOptions, Option[string]{
|
||||||
|
Key: item,
|
||||||
|
Value: item,
|
||||||
|
selected: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m.options = append(m.options, newOptions...)
|
||||||
|
filteredNewOptions := []Option[string]{}
|
||||||
|
for _, item := range newOptions {
|
||||||
|
if m.filterFunc(item.Key) {
|
||||||
|
filteredNewOptions = append(filteredNewOptions, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.filteredOptions = append(m.filteredOptions, filteredNewOptions...)
|
||||||
|
m.newInputActive = false
|
||||||
|
m.newInput.SetValue("")
|
||||||
|
m.newInput.Blur()
|
||||||
|
case key.Matches(msg, m.common.Keymap.Back):
|
||||||
|
m.newInputActive = false
|
||||||
|
m.newInput.Blur()
|
||||||
|
return m, SuppressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
|
||||||
|
m.err = nil
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keymap.Filter):
|
||||||
|
m.setFilter(true)
|
||||||
|
return m, m.filter.Focus()
|
||||||
|
case key.Matches(msg, m.keymap.SetFilter) && m.filtering:
|
||||||
|
if len(m.filteredOptions) <= 0 {
|
||||||
|
m.filter.SetValue("")
|
||||||
|
m.filteredOptions = m.options
|
||||||
|
}
|
||||||
|
m.setFilter(false)
|
||||||
|
case key.Matches(msg, m.common.Keymap.Back) && m.filtering:
|
||||||
|
m.filter.SetValue("")
|
||||||
|
m.filteredOptions = m.options
|
||||||
|
m.setFilter(false)
|
||||||
|
case key.Matches(msg, m.keymap.ClearFilter):
|
||||||
|
m.filter.SetValue("")
|
||||||
|
m.filteredOptions = m.options
|
||||||
|
m.setFilter(false)
|
||||||
|
case key.Matches(msg, m.keymap.Up):
|
||||||
|
if m.filtering && msg.String() == "k" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cursor = max(m.cursor-1, 0)
|
||||||
|
if m.cursor < m.viewport.YOffset {
|
||||||
|
m.viewport.SetYOffset(m.cursor)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keymap.Down):
|
||||||
|
if m.filtering && msg.String() == "j" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cursor = min(m.cursor+1, len(m.filteredOptions)-1)
|
||||||
|
if m.cursor >= m.viewport.YOffset+m.viewport.Height {
|
||||||
|
m.viewport.LineDown(1)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keymap.GotoTop):
|
||||||
|
if m.filtering {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.cursor = 0
|
||||||
|
m.viewport.GotoTop()
|
||||||
|
case key.Matches(msg, m.keymap.GotoBottom):
|
||||||
|
if m.filtering {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.cursor = len(m.filteredOptions) - 1
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
case key.Matches(msg, m.keymap.HalfPageUp):
|
||||||
|
m.cursor = max(m.cursor-m.viewport.Height/2, 0)
|
||||||
|
m.viewport.HalfViewUp()
|
||||||
|
case key.Matches(msg, m.keymap.HalfPageDown):
|
||||||
|
m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1)
|
||||||
|
m.viewport.HalfViewDown()
|
||||||
|
case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
|
||||||
|
if m.hasNewOption && m.cursor == 0 {
|
||||||
|
m.newInputActive = true
|
||||||
|
m.newInput.Focus()
|
||||||
|
} else {
|
||||||
|
for i, option := range m.options {
|
||||||
|
if option.Key == m.filteredOptions[m.cursor].Key {
|
||||||
|
if !m.options[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
selected := m.options[i].selected
|
||||||
|
m.options[i].selected = !selected
|
||||||
|
m.filteredOptions[m.cursor].selected = !selected
|
||||||
|
m.finalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keymap.Prev):
|
||||||
|
m.finalize()
|
||||||
|
if m.err != nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, huh.PrevField
|
||||||
|
case key.Matches(msg, m.keymap.Next, m.keymap.Submit):
|
||||||
|
m.finalize()
|
||||||
|
if m.err != nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, huh.NextField
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.filtering {
|
||||||
|
m.filteredOptions = m.options
|
||||||
|
if m.filter.Value() != "" {
|
||||||
|
m.filteredOptions = nil
|
||||||
|
for _, option := range m.options {
|
||||||
|
if m.filterFunc(option.Key) {
|
||||||
|
m.filteredOptions = append(m.filteredOptions, option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.filteredOptions) > 0 {
|
||||||
|
m.cursor = min(m.cursor, len(m.filteredOptions)-1)
|
||||||
|
m.viewport.SetYOffset(clamp(m.cursor, 0, len(m.filteredOptions)-m.viewport.Height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateViewportHeight updates the viewport size according to the Height setting
|
||||||
|
// on this multi-select field.
|
||||||
|
func (m *MultiSelect) updateViewportHeight() {
|
||||||
|
// If no height is set size the viewport to the number of options.
|
||||||
|
if m.height <= 0 {
|
||||||
|
m.viewport.Height = len(m.options)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const minHeight = 1
|
||||||
|
m.viewport.Height = max(minHeight, m.height-
|
||||||
|
lipgloss.Height(m.titleView())-
|
||||||
|
lipgloss.Height(m.descriptionView()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) numSelected() int {
|
||||||
|
var count int
|
||||||
|
for _, o := range m.options {
|
||||||
|
if o.selected {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) finalize() {
|
||||||
|
*m.value = make([]string, 0)
|
||||||
|
for _, option := range m.options {
|
||||||
|
if option.selected {
|
||||||
|
*m.value = append(*m.value, option.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.err = m.validate(*m.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) activeStyles() *huh.FieldStyles {
|
||||||
|
theme := m.theme
|
||||||
|
if theme == nil {
|
||||||
|
theme = huh.ThemeCharm()
|
||||||
|
}
|
||||||
|
if m.focused {
|
||||||
|
return &theme.Focused
|
||||||
|
}
|
||||||
|
return &theme.Blurred
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) titleView() string {
|
||||||
|
if m.title == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
styles = m.activeStyles()
|
||||||
|
sb = strings.Builder{}
|
||||||
|
)
|
||||||
|
if m.filtering {
|
||||||
|
sb.WriteString(m.filter.View())
|
||||||
|
} else if m.filter.Value() != "" {
|
||||||
|
sb.WriteString(styles.Title.Render(m.title) + styles.Description.Render("/"+m.filter.Value()))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styles.Title.Render(m.title))
|
||||||
|
}
|
||||||
|
if m.err != nil {
|
||||||
|
sb.WriteString(styles.ErrorIndicator.String())
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) descriptionView() string {
|
||||||
|
return m.activeStyles().Description.Render(m.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) choicesView() string {
|
||||||
|
var (
|
||||||
|
styles = m.activeStyles()
|
||||||
|
c = styles.MultiSelectSelector.String()
|
||||||
|
sb strings.Builder
|
||||||
|
)
|
||||||
|
for i, option := range m.filteredOptions {
|
||||||
|
if m.newInputActive && i == 0 {
|
||||||
|
sb.WriteString(c)
|
||||||
|
sb.WriteString(m.newInput.View())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
continue
|
||||||
|
} else if m.cursor == i {
|
||||||
|
sb.WriteString(c)
|
||||||
|
} else {
|
||||||
|
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.filteredOptions[i].selected {
|
||||||
|
sb.WriteString(styles.SelectedPrefix.String())
|
||||||
|
sb.WriteString(styles.SelectedOption.Render(option.Key))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styles.UnselectedPrefix.String())
|
||||||
|
sb.WriteString(styles.UnselectedOption.Render(option.Key))
|
||||||
|
}
|
||||||
|
if i < len(m.options)-1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(m.filteredOptions); i < len(m.options)-1; i++ {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the multi-select field.
|
||||||
|
func (m *MultiSelect) View() string {
|
||||||
|
styles := m.activeStyles()
|
||||||
|
m.viewport.SetContent(m.choicesView())
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if m.title != "" {
|
||||||
|
sb.WriteString(m.titleView())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
if m.description != "" {
|
||||||
|
sb.WriteString(m.descriptionView() + "\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(m.viewport.View())
|
||||||
|
return styles.Base.Render(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiSelect) printOptions() {
|
||||||
|
styles := m.activeStyles()
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(styles.Title.Render(m.title))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
for i, option := range m.options {
|
||||||
|
if option.selected {
|
||||||
|
sb.WriteString(styles.SelectedOption.Render(fmt.Sprintf("%d. %s %s", i+1, "✓", option.Key)))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s %s", i+1, " ", option.Key))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFilter sets the filter of the select field.
|
||||||
|
func (m *MultiSelect) setFilter(filter bool) {
|
||||||
|
m.filtering = filter
|
||||||
|
m.keymap.SetFilter.SetEnabled(filter)
|
||||||
|
m.keymap.Filter.SetEnabled(!filter)
|
||||||
|
m.keymap.Next.SetEnabled(!filter)
|
||||||
|
m.keymap.Submit.SetEnabled(!filter)
|
||||||
|
m.keymap.Prev.SetEnabled(!filter)
|
||||||
|
m.keymap.ClearFilter.SetEnabled(!filter && m.filter.Value() != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterFunc returns true if the option matches the filter.
|
||||||
|
func (m *MultiSelect) filterFunc(option string) bool {
|
||||||
|
// XXX: remove diacritics or allow customization of filter function.
|
||||||
|
return strings.Contains(strings.ToLower(option), strings.ToLower(m.filter.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs the multi-select field.
|
||||||
|
func (m *MultiSelect) Run() error {
|
||||||
|
if m.accessible {
|
||||||
|
return m.runAccessible()
|
||||||
|
}
|
||||||
|
return huh.Run(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAccessible() runs the multi-select field in accessible mode.
|
||||||
|
func (m *MultiSelect) runAccessible() error {
|
||||||
|
m.printOptions()
|
||||||
|
styles := m.activeStyles()
|
||||||
|
|
||||||
|
var choice int
|
||||||
|
for {
|
||||||
|
fmt.Printf("Select up to %d options. 0 to continue.\n", m.limit)
|
||||||
|
|
||||||
|
choice = accessibility.PromptInt("Select: ", 0, len(m.options))
|
||||||
|
if choice == 0 {
|
||||||
|
m.finalize()
|
||||||
|
err := m.validate(*m.value)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.options[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit {
|
||||||
|
fmt.Printf("You can't select more than %d options.\n", m.limit)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.options[choice-1].selected = !m.options[choice-1].selected
|
||||||
|
if m.options[choice-1].selected {
|
||||||
|
fmt.Printf("Selected: %s\n\n", m.options[choice-1].Key)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Deselected: %s\n\n", m.options[choice-1].Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.printOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
|
||||||
|
for _, option := range m.options {
|
||||||
|
if option.selected {
|
||||||
|
*m.value = append(*m.value, option.Value)
|
||||||
|
values = append(values, option.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(styles.SelectedOption.Render("Selected:", strings.Join(values, ", ")+"\n"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTheme sets the theme of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithTheme(theme *huh.Theme) huh.Field {
|
||||||
|
if m.theme != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.theme = theme
|
||||||
|
m.filter.Cursor.Style = m.theme.Focused.TextInput.Cursor
|
||||||
|
m.filter.PromptStyle = m.theme.Focused.TextInput.Prompt
|
||||||
|
m.updateViewportHeight()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeyMap sets the keymap of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithKeyMap(k *huh.KeyMap) huh.Field {
|
||||||
|
m.keymap = k.MultiSelect
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAccessible sets the accessible mode of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithAccessible(accessible bool) huh.Field {
|
||||||
|
m.accessible = accessible
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWidth sets the width of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithWidth(width int) huh.Field {
|
||||||
|
m.width = width
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHeight sets the height of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithHeight(height int) huh.Field {
|
||||||
|
m.height = height
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPosition sets the position of the multi-select field.
|
||||||
|
func (m *MultiSelect) WithPosition(p huh.FieldPosition) huh.Field {
|
||||||
|
if m.filtering {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.keymap.Prev.SetEnabled(!p.IsFirst())
|
||||||
|
m.keymap.Next.SetEnabled(!p.IsLast())
|
||||||
|
m.keymap.Submit.SetEnabled(p.IsLast())
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKey returns the multi-select's key.
|
||||||
|
func (m *MultiSelect) GetKey() string {
|
||||||
|
return m.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue returns the multi-select's value.
|
||||||
|
func (m *MultiSelect) GetValue() any {
|
||||||
|
return *m.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(n, low, high int) int {
|
||||||
|
if low > high {
|
||||||
|
low, high = high, low
|
||||||
|
}
|
||||||
|
return min(high, max(low, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
type SuppressBackMsg struct{}
|
||||||
|
|
||||||
|
func SuppressBack() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return SuppressBackMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
components/input/option.go
Normal file
38
components/input/option.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package input
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Option is an option for select fields.
|
||||||
|
type Option[T comparable] struct {
|
||||||
|
Key string
|
||||||
|
Value T
|
||||||
|
selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOptions returns new options from a list of values.
|
||||||
|
func NewOptions[T comparable](values ...T) []Option[T] {
|
||||||
|
options := make([]Option[T], len(values))
|
||||||
|
for i, o := range values {
|
||||||
|
options[i] = Option[T]{
|
||||||
|
Key: fmt.Sprint(o),
|
||||||
|
Value: o,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOption returns a new select option.
|
||||||
|
func NewOption[T comparable](key string, value T) Option[T] {
|
||||||
|
return Option[T]{Key: key, Value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected sets whether the option is currently selected.
|
||||||
|
func (o Option[T]) Selected(selected bool) Option[T] {
|
||||||
|
o.selected = selected
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the key of the option.
|
||||||
|
func (o Option[T]) String() string {
|
||||||
|
return o.Key
|
||||||
|
}
|
||||||
618
components/input/select.go
Normal file
618
components/input/select.go
Normal file
@ -0,0 +1,618 @@
|
|||||||
|
package input
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"tasksquire/common"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/huh/accessibility"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Select is a form select field.
|
||||||
|
type Select struct {
|
||||||
|
common *common.Common
|
||||||
|
|
||||||
|
value *string
|
||||||
|
key string
|
||||||
|
viewport viewport.Model
|
||||||
|
|
||||||
|
// customization
|
||||||
|
title string
|
||||||
|
description string
|
||||||
|
options []Option[string]
|
||||||
|
filteredOptions []Option[string]
|
||||||
|
height int
|
||||||
|
|
||||||
|
// error handling
|
||||||
|
validate func(string) error
|
||||||
|
err error
|
||||||
|
|
||||||
|
// state
|
||||||
|
selected int
|
||||||
|
focused bool
|
||||||
|
filtering bool
|
||||||
|
filter textinput.Model
|
||||||
|
|
||||||
|
// options
|
||||||
|
inline bool
|
||||||
|
width int
|
||||||
|
accessible bool
|
||||||
|
theme *huh.Theme
|
||||||
|
keymap huh.SelectKeyMap
|
||||||
|
|
||||||
|
// new
|
||||||
|
hasNewOption bool
|
||||||
|
newInput textinput.Model
|
||||||
|
newInputActive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSelect returns a new select field.
|
||||||
|
func NewSelect(com *common.Common) *Select {
|
||||||
|
filter := textinput.New()
|
||||||
|
filter.Prompt = "/"
|
||||||
|
|
||||||
|
newInput := textinput.New()
|
||||||
|
newInput.Prompt = "New: "
|
||||||
|
|
||||||
|
return &Select{
|
||||||
|
common: com,
|
||||||
|
options: []Option[string]{},
|
||||||
|
value: new(string),
|
||||||
|
validate: func(string) error { return nil },
|
||||||
|
filtering: false,
|
||||||
|
filter: filter,
|
||||||
|
newInput: newInput,
|
||||||
|
newInputActive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value sets the value of the select field.
|
||||||
|
func (s *Select) Value(value *string) *Select {
|
||||||
|
s.value = value
|
||||||
|
s.selectValue(*value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) selectValue(value string) {
|
||||||
|
for i, o := range s.options {
|
||||||
|
if o.Value == value {
|
||||||
|
s.selected = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key sets the key of the select field which can be used to retrieve the value
|
||||||
|
// after submission.
|
||||||
|
func (s *Select) Key(key string) *Select {
|
||||||
|
s.key = key
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title sets the title of the select field.
|
||||||
|
func (s *Select) Title(title string) *Select {
|
||||||
|
s.title = title
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description sets the description of the select field.
|
||||||
|
func (s *Select) Description(description string) *Select {
|
||||||
|
s.description = description
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options sets the options of the select field.
|
||||||
|
func (s *Select) Options(hasNewOption bool, options ...Option[string]) *Select {
|
||||||
|
s.hasNewOption = hasNewOption
|
||||||
|
|
||||||
|
if s.hasNewOption {
|
||||||
|
newOption := []Option[string]{
|
||||||
|
{Key: "(new)", Value: ""},
|
||||||
|
}
|
||||||
|
options = append(newOption, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(options) <= 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.options = options
|
||||||
|
s.filteredOptions = options
|
||||||
|
|
||||||
|
// Set the cursor to the existing value or the last selected option.
|
||||||
|
for i, option := range options {
|
||||||
|
if option.Value == *s.value {
|
||||||
|
s.selected = i
|
||||||
|
break
|
||||||
|
} else if option.selected {
|
||||||
|
s.selected = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.updateViewportHeight()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline sets whether the select input should be inline.
|
||||||
|
func (s *Select) Inline(v bool) *Select {
|
||||||
|
s.inline = v
|
||||||
|
if v {
|
||||||
|
s.Height(1)
|
||||||
|
}
|
||||||
|
s.keymap.Left.SetEnabled(v)
|
||||||
|
s.keymap.Right.SetEnabled(v)
|
||||||
|
s.keymap.Up.SetEnabled(!v)
|
||||||
|
s.keymap.Down.SetEnabled(!v)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height sets the height of the select field. If the number of options
|
||||||
|
// exceeds the height, the select field will become scrollable.
|
||||||
|
func (s *Select) Height(height int) *Select {
|
||||||
|
s.height = height
|
||||||
|
s.updateViewportHeight()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sets the validation function of the select field.
|
||||||
|
func (s *Select) Validate(validate func(string) error) *Select {
|
||||||
|
s.validate = validate
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the error of the select field.
|
||||||
|
func (s *Select) Error() error {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip returns whether the select should be skipped or should be blocking.
|
||||||
|
func (*Select) Skip() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom returns whether the input should be zoomed.
|
||||||
|
func (*Select) Zoom() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the select field.
|
||||||
|
func (s *Select) Focus() tea.Cmd {
|
||||||
|
s.focused = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the select field.
|
||||||
|
func (s *Select) Blur() tea.Cmd {
|
||||||
|
value := *s.value
|
||||||
|
if s.inline {
|
||||||
|
s.clearFilter()
|
||||||
|
s.selectValue(value)
|
||||||
|
}
|
||||||
|
s.focused = false
|
||||||
|
s.err = s.validate(value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyBinds returns the help keybindings for the select field.
|
||||||
|
func (s *Select) KeyBinds() []key.Binding {
|
||||||
|
return []key.Binding{
|
||||||
|
s.keymap.Up,
|
||||||
|
s.keymap.Down,
|
||||||
|
s.keymap.Left,
|
||||||
|
s.keymap.Right,
|
||||||
|
s.keymap.Filter,
|
||||||
|
s.keymap.SetFilter,
|
||||||
|
s.keymap.ClearFilter,
|
||||||
|
s.keymap.Prev,
|
||||||
|
s.keymap.Next,
|
||||||
|
s.keymap.Submit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the select field.
|
||||||
|
func (s *Select) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates the select field.
|
||||||
|
func (s *Select) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
s.updateViewportHeight()
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if s.filtering {
|
||||||
|
s.filter, cmd = s.filter.Update(msg)
|
||||||
|
|
||||||
|
// Keep the selected item in view.
|
||||||
|
if s.selected < s.viewport.YOffset || s.selected >= s.viewport.YOffset+s.viewport.Height {
|
||||||
|
s.viewport.SetYOffset(s.selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.newInputActive {
|
||||||
|
s.newInput, cmd = s.newInput.Update(msg)
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, s.common.Keymap.Ok):
|
||||||
|
if s.newInput.Value() != "" {
|
||||||
|
newOption := Option[string]{
|
||||||
|
Key: s.newInput.Value(),
|
||||||
|
Value: s.newInput.Value(),
|
||||||
|
selected: true,
|
||||||
|
}
|
||||||
|
s.options = append(s.options, newOption)
|
||||||
|
if s.filterFunc(newOption.Key) {
|
||||||
|
s.filteredOptions = append(s.filteredOptions, newOption)
|
||||||
|
}
|
||||||
|
s.selected = len(s.options) - 1
|
||||||
|
|
||||||
|
value := newOption.Value
|
||||||
|
s.setFiltering(false)
|
||||||
|
s.err = s.validate(value)
|
||||||
|
if s.err != nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
*s.value = value
|
||||||
|
}
|
||||||
|
s.newInputActive = false
|
||||||
|
s.newInput.SetValue("")
|
||||||
|
s.newInput.Blur()
|
||||||
|
return s, nil
|
||||||
|
case key.Matches(msg, s.common.Keymap.Back):
|
||||||
|
s.newInputActive = false
|
||||||
|
s.newInput.Blur()
|
||||||
|
return s, SuppressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
s.err = nil
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, s.keymap.Filter):
|
||||||
|
s.setFiltering(true)
|
||||||
|
return s, s.filter.Focus()
|
||||||
|
case key.Matches(msg, s.keymap.SetFilter):
|
||||||
|
if len(s.filteredOptions) <= 0 {
|
||||||
|
s.filter.SetValue("")
|
||||||
|
s.filteredOptions = s.options
|
||||||
|
}
|
||||||
|
s.setFiltering(false)
|
||||||
|
case key.Matches(msg, s.keymap.ClearFilter):
|
||||||
|
s.clearFilter()
|
||||||
|
case key.Matches(msg, s.keymap.Up, s.keymap.Left):
|
||||||
|
// When filtering we should ignore j/k keybindings
|
||||||
|
//
|
||||||
|
// XXX: Currently, the below check doesn't account for keymap
|
||||||
|
// changes. When making this fix it's worth considering ignoring
|
||||||
|
// whether to ignore all up/down keybindings as ignoring a-zA-Z0-9
|
||||||
|
// may not be enough when international keyboards are considered.
|
||||||
|
if s.filtering && (msg.String() == "k" || msg.String() == "h") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.selected = max(s.selected-1, 0)
|
||||||
|
if s.selected < s.viewport.YOffset {
|
||||||
|
s.viewport.SetYOffset(s.selected)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, s.keymap.GotoTop):
|
||||||
|
if s.filtering {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.selected = 0
|
||||||
|
s.viewport.GotoTop()
|
||||||
|
case key.Matches(msg, s.keymap.GotoBottom):
|
||||||
|
if s.filtering {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.selected = len(s.filteredOptions) - 1
|
||||||
|
s.viewport.GotoBottom()
|
||||||
|
case key.Matches(msg, s.keymap.HalfPageUp):
|
||||||
|
s.selected = max(s.selected-s.viewport.Height/2, 0)
|
||||||
|
s.viewport.HalfViewUp()
|
||||||
|
case key.Matches(msg, s.keymap.HalfPageDown):
|
||||||
|
s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1)
|
||||||
|
s.viewport.HalfViewDown()
|
||||||
|
case key.Matches(msg, s.keymap.Down, s.keymap.Right):
|
||||||
|
// When filtering we should ignore j/k keybindings
|
||||||
|
//
|
||||||
|
// XXX: See note in the previous case match.
|
||||||
|
if s.filtering && (msg.String() == "j" || msg.String() == "l") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.selected = min(s.selected+1, len(s.filteredOptions)-1)
|
||||||
|
if s.selected >= s.viewport.YOffset+s.viewport.Height {
|
||||||
|
s.viewport.LineDown(1)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, s.keymap.Prev):
|
||||||
|
if s.selected >= len(s.filteredOptions) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := s.filteredOptions[s.selected].Value
|
||||||
|
s.err = s.validate(value)
|
||||||
|
if s.err != nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
*s.value = value
|
||||||
|
return s, huh.PrevField
|
||||||
|
case key.Matches(msg, s.common.Keymap.Select):
|
||||||
|
if s.hasNewOption && s.selected == 0 {
|
||||||
|
s.newInputActive = true
|
||||||
|
s.newInput.Focus()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
case key.Matches(msg, s.keymap.Next, s.keymap.Submit):
|
||||||
|
if s.selected >= len(s.filteredOptions) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := s.filteredOptions[s.selected].Value
|
||||||
|
s.setFiltering(false)
|
||||||
|
s.err = s.validate(value)
|
||||||
|
if s.err != nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
*s.value = value
|
||||||
|
return s, huh.NextField
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.filtering {
|
||||||
|
s.filteredOptions = s.options
|
||||||
|
if s.filter.Value() != "" {
|
||||||
|
s.filteredOptions = nil
|
||||||
|
for _, option := range s.options {
|
||||||
|
if s.filterFunc(option.Key) {
|
||||||
|
s.filteredOptions = append(s.filteredOptions, option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.filteredOptions) > 0 {
|
||||||
|
s.selected = min(s.selected, len(s.filteredOptions)-1)
|
||||||
|
s.viewport.SetYOffset(clamp(s.selected, 0, len(s.filteredOptions)-s.viewport.Height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateViewportHeight updates the viewport size according to the Height setting
|
||||||
|
// on this select field.
|
||||||
|
func (s *Select) updateViewportHeight() {
|
||||||
|
// If no height is set size the viewport to the number of options.
|
||||||
|
if s.height <= 0 {
|
||||||
|
s.viewport.Height = len(s.options)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const minHeight = 1
|
||||||
|
s.viewport.Height = max(minHeight, s.height-
|
||||||
|
lipgloss.Height(s.titleView())-
|
||||||
|
lipgloss.Height(s.descriptionView()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) activeStyles() *huh.FieldStyles {
|
||||||
|
theme := s.theme
|
||||||
|
if theme == nil {
|
||||||
|
theme = huh.ThemeCharm()
|
||||||
|
}
|
||||||
|
if s.focused {
|
||||||
|
return &theme.Focused
|
||||||
|
}
|
||||||
|
return &theme.Blurred
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) titleView() string {
|
||||||
|
if s.title == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
styles = s.activeStyles()
|
||||||
|
sb = strings.Builder{}
|
||||||
|
)
|
||||||
|
if s.filtering {
|
||||||
|
sb.WriteString(styles.Title.Render(s.filter.View()))
|
||||||
|
} else if s.filter.Value() != "" && !s.inline {
|
||||||
|
sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value()))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styles.Title.Render(s.title))
|
||||||
|
}
|
||||||
|
if s.err != nil {
|
||||||
|
sb.WriteString(styles.ErrorIndicator.String())
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) descriptionView() string {
|
||||||
|
return s.activeStyles().Description.Render(s.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Select) choicesView() string {
|
||||||
|
var (
|
||||||
|
styles = s.activeStyles()
|
||||||
|
c = styles.SelectSelector.String()
|
||||||
|
sb strings.Builder
|
||||||
|
)
|
||||||
|
|
||||||
|
if s.inline {
|
||||||
|
sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String())
|
||||||
|
if len(s.filteredOptions) > 0 {
|
||||||
|
sb.WriteString(styles.SelectedOption.Render(s.filteredOptions[s.selected].Key))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styles.TextInput.Placeholder.Render("No matches"))
|
||||||
|
}
|
||||||
|
sb.WriteString(styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String())
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, option := range s.filteredOptions {
|
||||||
|
if s.newInputActive && i == 0 {
|
||||||
|
sb.WriteString(c)
|
||||||
|
sb.WriteString(s.newInput.View())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
continue
|
||||||
|
} else if s.selected == i {
|
||||||
|
sb.WriteString(c + styles.SelectedOption.Render(option.Key))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key))
|
||||||
|
}
|
||||||
|
if i < len(s.options)-1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(s.filteredOptions); i < len(s.options)-1; i++ {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the select field.
|
||||||
|
func (s *Select) View() string {
|
||||||
|
styles := s.activeStyles()
|
||||||
|
s.viewport.SetContent(s.choicesView())
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if s.title != "" {
|
||||||
|
sb.WriteString(s.titleView())
|
||||||
|
if !s.inline {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.description != "" {
|
||||||
|
sb.WriteString(s.descriptionView())
|
||||||
|
if !s.inline {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(s.viewport.View())
|
||||||
|
return styles.Base.Render(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearFilter clears the value of the filter.
|
||||||
|
func (s *Select) clearFilter() {
|
||||||
|
s.filter.SetValue("")
|
||||||
|
s.filteredOptions = s.options
|
||||||
|
s.setFiltering(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFiltering sets the filter of the select field.
|
||||||
|
func (s *Select) setFiltering(filtering bool) {
|
||||||
|
if s.inline && filtering {
|
||||||
|
s.filter.Width = lipgloss.Width(s.titleView()) - 1 - 1
|
||||||
|
}
|
||||||
|
s.filtering = filtering
|
||||||
|
s.keymap.SetFilter.SetEnabled(filtering)
|
||||||
|
s.keymap.Filter.SetEnabled(!filtering)
|
||||||
|
s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterFunc returns true if the option matches the filter.
|
||||||
|
func (s *Select) filterFunc(option string) bool {
|
||||||
|
// XXX: remove diacritics or allow customization of filter function.
|
||||||
|
return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs the select field.
|
||||||
|
func (s *Select) Run() error {
|
||||||
|
if s.accessible {
|
||||||
|
return s.runAccessible()
|
||||||
|
}
|
||||||
|
return huh.Run(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAccessible runs an accessible select field.
|
||||||
|
func (s *Select) runAccessible() error {
|
||||||
|
var sb strings.Builder
|
||||||
|
styles := s.activeStyles()
|
||||||
|
|
||||||
|
sb.WriteString(styles.Title.Render(s.title) + "\n")
|
||||||
|
|
||||||
|
for i, option := range s.options {
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(sb.String())
|
||||||
|
|
||||||
|
for {
|
||||||
|
choice := accessibility.PromptInt("Choose: ", 1, len(s.options))
|
||||||
|
option := s.options[choice-1]
|
||||||
|
if err := s.validate(option.Value); err != nil {
|
||||||
|
fmt.Println(err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println(styles.SelectedOption.Render("Chose: " + option.Key + "\n"))
|
||||||
|
*s.value = option.Value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTheme sets the theme of the select field.
|
||||||
|
func (s *Select) WithTheme(theme *huh.Theme) huh.Field {
|
||||||
|
if s.theme != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.theme = theme
|
||||||
|
s.filter.Cursor.Style = s.theme.Focused.TextInput.Cursor
|
||||||
|
s.filter.PromptStyle = s.theme.Focused.TextInput.Prompt
|
||||||
|
s.updateViewportHeight()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeyMap sets the keymap on a select field.
|
||||||
|
func (s *Select) WithKeyMap(k *huh.KeyMap) huh.Field {
|
||||||
|
s.keymap = k.Select
|
||||||
|
s.keymap.Left.SetEnabled(s.inline)
|
||||||
|
s.keymap.Right.SetEnabled(s.inline)
|
||||||
|
s.keymap.Up.SetEnabled(!s.inline)
|
||||||
|
s.keymap.Down.SetEnabled(!s.inline)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAccessible sets the accessible mode of the select field.
|
||||||
|
func (s *Select) WithAccessible(accessible bool) huh.Field {
|
||||||
|
s.accessible = accessible
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWidth sets the width of the select field.
|
||||||
|
func (s *Select) WithWidth(width int) huh.Field {
|
||||||
|
s.width = width
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHeight sets the height of the select field.
|
||||||
|
func (s *Select) WithHeight(height int) huh.Field {
|
||||||
|
return s.Height(height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPosition sets the position of the select field.
|
||||||
|
func (s *Select) WithPosition(p huh.FieldPosition) huh.Field {
|
||||||
|
if s.filtering {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.keymap.Prev.SetEnabled(!p.IsFirst())
|
||||||
|
s.keymap.Next.SetEnabled(!p.IsLast())
|
||||||
|
s.keymap.Submit.SetEnabled(p.IsLast())
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKey returns the key of the field.
|
||||||
|
func (s *Select) GetKey() string {
|
||||||
|
return s.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue returns the value of the field.
|
||||||
|
func (s *Select) GetValue() any {
|
||||||
|
return *s.value
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package table
|
package table
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -148,59 +149,89 @@ func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style {
|
|||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return styles
|
return styles
|
||||||
}
|
}
|
||||||
|
taskstyle:
|
||||||
for i, task := range rows {
|
for i, task := range rows {
|
||||||
if task.Status == "deleted" {
|
if task.Status == "deleted" {
|
||||||
styles[i] = m.common.Styles.Deleted.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["deleted"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if task.Status == "completed" {
|
if task.Status == "completed" {
|
||||||
styles[i] = m.common.Styles.Completed.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["completed"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if task.Status == "pending" && task.Start != "" {
|
if task.Status == "pending" && task.Start != "" {
|
||||||
styles[i] = m.common.Styles.Active.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// TODO: implement keyword
|
// TODO: implement keyword
|
||||||
// TODO: implement tag
|
// TODO: implement tag
|
||||||
|
if task.HasTag("next") {
|
||||||
|
if c, ok := m.common.Styles.Colors["tag.next"]; ok && c != nil {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
// TODO: implement project
|
// TODO: implement project
|
||||||
if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) {
|
if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) {
|
||||||
styles[i] = m.common.Styles.Overdue.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["overdue"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if task.Scheduled != "" {
|
if task.Scheduled != "" {
|
||||||
styles[i] = m.common.Styles.Scheduled.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["scheduled"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) {
|
if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) {
|
||||||
styles[i] = m.common.Styles.DueToday.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["due.today"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if task.Due != "" {
|
if task.Due != "" {
|
||||||
styles[i] = m.common.Styles.Due.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["due"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(task.Depends) > 0 {
|
if len(task.Depends) > 0 {
|
||||||
styles[i] = m.common.Styles.Blocked.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["blocked"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// TODO implement blocking
|
// TODO implement blocking
|
||||||
if task.Recur != "" {
|
if task.Recur != "" {
|
||||||
styles[i] = m.common.Styles.Recurring.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["recurring"]; ok && c != nil {
|
||||||
continue
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// TODO: make styles optional and discard if empty
|
||||||
if len(task.Tags) > 0 {
|
if len(task.Tags) > 0 {
|
||||||
styles[i] = m.common.Styles.Tagged.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
if c, ok := m.common.Styles.Colors["tagged"]; ok && c != nil {
|
||||||
taskIteration:
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
for _, tag := range task.Tags {
|
continue
|
||||||
if tag == "next" {
|
}
|
||||||
styles[i] = m.common.Styles.TagNext.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
}
|
||||||
break taskIteration
|
if len(m.common.Udas) > 0 {
|
||||||
|
for _, uda := range m.common.Udas {
|
||||||
|
if u, ok := task.Udas[uda.Name]; ok {
|
||||||
|
if c, ok := m.common.Styles.Colors[fmt.Sprintf("uda.%s.%s", uda.Name, u)]; ok {
|
||||||
|
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
|
continue taskstyle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
// TODO implement uda
|
|
||||||
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
||||||
}
|
}
|
||||||
return styles
|
return styles
|
||||||
|
|||||||
23
go.mod
23
go.mod
@ -4,18 +4,24 @@ go 1.22.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v0.18.0
|
github.com/charmbracelet/bubbles v0.18.0
|
||||||
github.com/charmbracelet/bubbletea v0.26.1
|
github.com/charmbracelet/bubbletea v0.26.4
|
||||||
github.com/charmbracelet/huh v0.3.0
|
github.com/charmbracelet/huh v0.4.2
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb
|
github.com/charmbracelet/lipgloss v0.11.0
|
||||||
github.com/mattn/go-runewidth v0.0.15
|
github.com/mattn/go-runewidth v0.0.15
|
||||||
golang.org/x/term v0.20.0
|
golang.org/x/term v0.21.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/catppuccin/go v0.2.0 // indirect
|
github.com/catppuccin/go v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 // indirect
|
github.com/charmbracelet/x/ansi v0.1.2 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect
|
||||||
|
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // indirect
|
||||||
|
github.com/charmbracelet/x/input v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.1.2 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@ -25,8 +31,9 @@ require (
|
|||||||
github.com/muesli/reflow v0.3.0 // indirect
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
github.com/muesli/termenv v0.15.2 // indirect
|
github.com/muesli/termenv v0.15.2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.7.0 // indirect
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.21.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
golang.org/x/text v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
48
go.sum
48
go.sum
@ -6,14 +6,26 @@ github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
|||||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
||||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
||||||
github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0=
|
github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40=
|
||||||
github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo=
|
github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0=
|
||||||
github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE=
|
github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM=
|
||||||
github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA=
|
github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0=
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb h1:Hs3xzxHuruNT2Iuo87iS40c0PhLqpnUKBI6Xw6Ad3wQ=
|
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
|
||||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs=
|
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 h1:+0U9qX8Pv8KiYgRxfBvORRjgBzLgHMjtElP4O0PyKYA=
|
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
|
||||||
github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c=
|
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 h1:6Rw/MHf+Rm7wlUr+euFyaGw1fNKeZPKVh4gkFHUjFsY=
|
||||||
|
github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7/go.mod h1:UZyEz89i6+jVx2oW3lgmIsVDNr7qzaKuvPVoSdwqDR0=
|
||||||
|
github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4=
|
||||||
|
github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0=
|
||||||
|
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
|
||||||
|
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
|
||||||
|
github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg=
|
||||||
|
github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
@ -39,15 +51,19 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
|||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
|
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
|||||||
13
main.go
13
main.go
@ -15,7 +15,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ts := taskwarrior.NewTaskSquire("./test/taskrc")
|
var taskrcPath string
|
||||||
|
if taskrcEnv := os.Getenv("TASKRC"); taskrcEnv != "" {
|
||||||
|
taskrcPath = taskrcEnv
|
||||||
|
} else if _, err := os.Stat(os.Getenv("HOME") + "/.taskrc"); err == nil {
|
||||||
|
taskrcPath = os.Getenv("HOME") + "/.taskrc"
|
||||||
|
} else if _, err := os.Stat(os.Getenv("HOME") + "/.config/task/taskrc"); err == nil {
|
||||||
|
taskrcPath = os.Getenv("HOME") + "/.config/task/taskrc"
|
||||||
|
} else {
|
||||||
|
log.Fatal("Unable to find taskrc file")
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := taskwarrior.NewTaskSquire(taskrcPath)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
common := common.NewCommon(ctx, ts)
|
common := common.NewCommon(ctx, ts)
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextPickerPage struct {
|
type ContextPickerPage struct {
|
||||||
@ -40,18 +41,32 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
|||||||
Options(huh.NewOptions(options...)...).
|
Options(huh.NewOptions(options...)...).
|
||||||
Title("Contexts").
|
Title("Contexts").
|
||||||
Description("Choose a context").
|
Description("Choose a context").
|
||||||
Value(&selected),
|
Value(&selected).
|
||||||
|
WithTheme(common.Styles.Form),
|
||||||
),
|
),
|
||||||
).
|
).
|
||||||
WithShowHelp(false).
|
WithShowHelp(false).
|
||||||
WithShowErrors(true).
|
WithShowErrors(true)
|
||||||
WithTheme(p.common.Styles.Form)
|
|
||||||
|
p.SetSize(common.Width(), common.Height())
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) SetSize(width, height int) {
|
func (p *ContextPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
|
if width >= 20 {
|
||||||
|
p.form = p.form.WithWidth(20)
|
||||||
|
} else {
|
||||||
|
p.form = p.form.WithWidth(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if height >= 30 {
|
||||||
|
p.form = p.form.WithHeight(30)
|
||||||
|
} else {
|
||||||
|
p.form = p.form.WithHeight(height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) Init() tea.Cmd {
|
func (p *ContextPickerPage) Init() tea.Cmd {
|
||||||
@ -96,7 +111,13 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) View() string {
|
func (p *ContextPickerPage) View() string {
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
return lipgloss.Place(
|
||||||
|
p.common.Width(),
|
||||||
|
p.common.Height(),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
p.common.Styles.Base.Render(p.form.View()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ContextPickerPage) updateContextCmd() tea.Msg {
|
func (p *ContextPickerPage) updateContextCmd() tea.Msg {
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import tea "github.com/charmbracelet/bubbletea"
|
import (
|
||||||
|
"tasksquire/taskwarrior"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
type UpdatedTasksMsg struct{}
|
type UpdatedTasksMsg struct{}
|
||||||
|
|
||||||
@ -52,9 +57,9 @@ func prevArea() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type changeAreaMsg area
|
type changeAreaMsg int
|
||||||
|
|
||||||
func changeArea(a area) tea.Cmd {
|
func changeArea(a int) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return changeAreaMsg(a)
|
return changeAreaMsg(a)
|
||||||
}
|
}
|
||||||
@ -67,3 +72,13 @@ func changeMode(mode mode) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type changeModeMsg mode
|
type changeModeMsg mode
|
||||||
|
|
||||||
|
type taskMsg taskwarrior.Tasks
|
||||||
|
|
||||||
|
type tickMsg time.Time
|
||||||
|
|
||||||
|
func doTick() tea.Cmd {
|
||||||
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return tickMsg(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectPickerPage struct {
|
type ProjectPickerPage struct {
|
||||||
@ -37,17 +38,32 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
|
|||||||
Options(huh.NewOptions(options...)...).
|
Options(huh.NewOptions(options...)...).
|
||||||
Title("Projects").
|
Title("Projects").
|
||||||
Description("Choose a project").
|
Description("Choose a project").
|
||||||
Value(&selected),
|
Value(&selected).
|
||||||
|
WithTheme(common.Styles.Form),
|
||||||
),
|
),
|
||||||
).
|
).
|
||||||
WithShowHelp(false).
|
WithShowHelp(false).
|
||||||
WithShowErrors(false)
|
WithShowErrors(false)
|
||||||
|
|
||||||
|
p.SetSize(common.Width(), common.Height())
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) SetSize(width, height int) {
|
func (p *ProjectPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
|
if width >= 20 {
|
||||||
|
p.form = p.form.WithWidth(20)
|
||||||
|
} else {
|
||||||
|
p.form = p.form.WithWidth(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if height >= 30 {
|
||||||
|
p.form = p.form.WithHeight(30)
|
||||||
|
} else {
|
||||||
|
p.form = p.form.WithHeight(height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) Init() tea.Cmd {
|
func (p *ProjectPickerPage) Init() tea.Cmd {
|
||||||
@ -92,7 +108,13 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) View() string {
|
func (p *ProjectPickerPage) View() string {
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
return lipgloss.Place(
|
||||||
|
p.common.Width(),
|
||||||
|
p.common.Height(),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
p.common.Styles.Base.Render(p.form.View()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
|
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/table"
|
// "github.com/charmbracelet/bubbles/table"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
@ -24,7 +25,7 @@ type ReportPage struct {
|
|||||||
|
|
||||||
taskTable table.Model
|
taskTable table.Model
|
||||||
|
|
||||||
subpage tea.Model
|
subpage common.Component
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
||||||
@ -54,12 +55,12 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
|
|||||||
func (p *ReportPage) SetSize(width int, height int) {
|
func (p *ReportPage) SetSize(width int, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
p.taskTable.SetWidth(width - p.common.Styles.Base.GetVerticalFrameSize())
|
p.taskTable.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
||||||
p.taskTable.SetHeight(height - p.common.Styles.Base.GetHorizontalFrameSize())
|
p.taskTable.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPage) Init() tea.Cmd {
|
func (p *ReportPage) Init() tea.Cmd {
|
||||||
return p.getTasks()
|
return tea.Batch(p.getTasks(), doTick())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@ -68,7 +69,11 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
p.SetSize(msg.Width, msg.Height)
|
p.SetSize(msg.Width, msg.Height)
|
||||||
// case BackMsg:
|
// case BackMsg:
|
||||||
case TaskMsg:
|
case tickMsg:
|
||||||
|
cmds = append(cmds, p.getTasks())
|
||||||
|
cmds = append(cmds, doTick())
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
case taskMsg:
|
||||||
p.tasks = taskwarrior.Tasks(msg)
|
p.tasks = taskwarrior.Tasks(msg)
|
||||||
p.populateTaskTable(p.tasks)
|
p.populateTaskTable(p.tasks)
|
||||||
case UpdateReportMsg:
|
case UpdateReportMsg:
|
||||||
@ -98,7 +103,7 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, nil
|
return p.subpage, nil
|
||||||
case key.Matches(msg, p.common.Keymap.Add):
|
case key.Matches(msg, p.common.Keymap.Add):
|
||||||
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
|
p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
|
||||||
p.subpage.Init()
|
p.subpage.Init()
|
||||||
p.common.PushPage(p)
|
p.common.PushPage(p)
|
||||||
return p.subpage, nil
|
return p.subpage, nil
|
||||||
@ -211,8 +216,6 @@ func (p *ReportPage) getTasks() tea.Cmd {
|
|||||||
filters = append(filters, "project:"+p.activeProject)
|
filters = append(filters, "project:"+p.activeProject)
|
||||||
}
|
}
|
||||||
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
|
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
|
||||||
return TaskMsg(tasks)
|
return taskMsg(tasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskMsg taskwarrior.Tasks
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReportPickerPage struct {
|
type ReportPickerPage struct {
|
||||||
@ -38,17 +39,32 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
|
|||||||
Options(huh.NewOptions(options...)...).
|
Options(huh.NewOptions(options...)...).
|
||||||
Title("Reports").
|
Title("Reports").
|
||||||
Description("Choose a report").
|
Description("Choose a report").
|
||||||
Value(&selected),
|
Value(&selected).
|
||||||
|
WithTheme(common.Styles.Form),
|
||||||
),
|
),
|
||||||
).
|
).
|
||||||
WithShowHelp(false).
|
WithShowHelp(false).
|
||||||
WithShowErrors(false)
|
WithShowErrors(false)
|
||||||
|
|
||||||
|
p.SetSize(common.Width(), common.Height())
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) SetSize(width, height int) {
|
func (p *ReportPickerPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
|
if width >= 20 {
|
||||||
|
p.form = p.form.WithWidth(20)
|
||||||
|
} else {
|
||||||
|
p.form = p.form.WithWidth(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
if height >= 30 {
|
||||||
|
p.form = p.form.WithHeight(30)
|
||||||
|
} else {
|
||||||
|
p.form = p.form.WithHeight(height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) Init() tea.Cmd {
|
func (p *ReportPickerPage) Init() tea.Cmd {
|
||||||
@ -93,7 +109,13 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) View() string {
|
func (p *ReportPickerPage) View() string {
|
||||||
return p.common.Styles.Base.Render(p.form.View())
|
return lipgloss.Place(
|
||||||
|
p.common.Width(),
|
||||||
|
p.common.Height(),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
p.common.Styles.Base.Render(p.form.View()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
|
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
|
||||||
|
|||||||
@ -3,14 +3,17 @@ package pages
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tasksquire/components/input"
|
||||||
"tasksquire/taskwarrior"
|
"tasksquire/taskwarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@ -27,13 +30,16 @@ type TaskEditorPage struct {
|
|||||||
common *common.Common
|
common *common.Common
|
||||||
task taskwarrior.Task
|
task taskwarrior.Task
|
||||||
|
|
||||||
|
colWidth int
|
||||||
|
colHeight int
|
||||||
|
|
||||||
mode mode
|
mode mode
|
||||||
|
|
||||||
columnCursor int
|
columnCursor int
|
||||||
|
|
||||||
area area
|
area int
|
||||||
areaPicker *areaPicker
|
areaPicker *areaPicker
|
||||||
areas map[area]tea.Model
|
areas []area
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
||||||
@ -42,23 +48,17 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
|
|||||||
task: task,
|
task: task,
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.task.Priority == "" {
|
|
||||||
p.task.Priority = "(none)"
|
|
||||||
}
|
|
||||||
if p.task.Project == "" {
|
if p.task.Project == "" {
|
||||||
p.task.Project = "(none)"
|
p.task.Project = "(none)"
|
||||||
}
|
}
|
||||||
|
|
||||||
priorityOptions := append([]string{"(none)"}, p.common.TW.GetPriorities()...)
|
|
||||||
projectOptions := append([]string{"(none)"}, p.common.TW.GetProjects()...)
|
|
||||||
tagOptions := p.common.TW.GetTags()
|
tagOptions := p.common.TW.GetTags()
|
||||||
tagOptions = append(tagOptions, strings.Split(p.common.TW.GetConfig().Get("uda.tasksquire.tags.default"), ",")...)
|
|
||||||
slices.Sort(tagOptions)
|
|
||||||
|
|
||||||
p.areas = map[area]tea.Model{
|
p.areas = []area{
|
||||||
areaTask: NewTaskEdit(p.common, &p.task.Description, &p.task.Priority, &p.task.Project, priorityOptions, projectOptions),
|
NewTaskEdit(p.common, &p.task),
|
||||||
areaTags: NewTagEdit(p.common, &p.task.Tags, tagOptions),
|
NewTagEdit(p.common, &p.task.Tags, tagOptions),
|
||||||
areaTime: NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
|
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
|
||||||
|
NewDetailsEdit(p.common, &p.task),
|
||||||
}
|
}
|
||||||
|
|
||||||
// p.areaList = NewAreaList(common, areaItems)
|
// p.areaList = NewAreaList(common, areaItems)
|
||||||
@ -69,17 +69,30 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
|
|||||||
|
|
||||||
p.columnCursor = 1
|
p.columnCursor = 1
|
||||||
if p.task.Uuid == "" {
|
if p.task.Uuid == "" {
|
||||||
// p.mode = modeInsert
|
|
||||||
p.mode = modeInsert
|
p.mode = modeInsert
|
||||||
} else {
|
} else {
|
||||||
p.mode = modeNormal
|
p.mode = modeNormal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.SetSize(com.Width(), com.Height())
|
||||||
|
|
||||||
return &p
|
return &p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TaskEditorPage) SetSize(width, height int) {
|
func (p *TaskEditorPage) SetSize(width, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
|
if width >= 70 {
|
||||||
|
p.colWidth = 70 - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
||||||
|
} else {
|
||||||
|
p.colWidth = width - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
if height >= 40 {
|
||||||
|
p.colHeight = 40 - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
||||||
|
} else {
|
||||||
|
p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TaskEditorPage) Init() tea.Cmd {
|
func (p *TaskEditorPage) Init() tea.Cmd {
|
||||||
@ -88,8 +101,10 @@ func (p *TaskEditorPage) Init() tea.Cmd {
|
|||||||
|
|
||||||
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
p.SetSize(msg.Width, msg.Height)
|
||||||
case changeAreaMsg:
|
case changeAreaMsg:
|
||||||
p.area = area(msg)
|
p.area = int(msg)
|
||||||
case changeModeMsg:
|
case changeModeMsg:
|
||||||
p.mode = mode(msg)
|
p.mode = mode(msg)
|
||||||
case prevColumnMsg:
|
case prevColumnMsg:
|
||||||
@ -105,13 +120,15 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case prevAreaMsg:
|
case prevAreaMsg:
|
||||||
p.area--
|
p.area--
|
||||||
if p.area < 0 {
|
if p.area < 0 {
|
||||||
p.area = 2
|
p.area = len(p.areas) - 1
|
||||||
}
|
}
|
||||||
|
p.areas[p.area].SetCursor(-1)
|
||||||
case nextAreaMsg:
|
case nextAreaMsg:
|
||||||
p.area++
|
p.area++
|
||||||
if p.area > 2 {
|
if p.area > len(p.areas)-1 {
|
||||||
p.area = 0
|
p.area = 0
|
||||||
}
|
}
|
||||||
|
p.areas[p.area].SetCursor(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch p.mode {
|
switch p.mode {
|
||||||
@ -135,28 +152,32 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return nil, tea.Quit
|
return nil, tea.Quit
|
||||||
}
|
}
|
||||||
return model, p.updateTasksCmd
|
return model, p.updateTasksCmd
|
||||||
|
case key.Matches(msg, p.common.Keymap.PrevPage):
|
||||||
|
return p, prevArea()
|
||||||
|
case key.Matches(msg, p.common.Keymap.NextPage):
|
||||||
|
return p, nextArea()
|
||||||
case key.Matches(msg, p.common.Keymap.Left):
|
case key.Matches(msg, p.common.Keymap.Left):
|
||||||
return p, prevColumn()
|
return p, prevColumn()
|
||||||
case key.Matches(msg, p.common.Keymap.Right):
|
case key.Matches(msg, p.common.Keymap.Right):
|
||||||
return p, nextColumn()
|
return p, nextColumn()
|
||||||
case key.Matches(msg, p.common.Keymap.Up):
|
case key.Matches(msg, p.common.Keymap.Up):
|
||||||
var cmd tea.Cmd
|
|
||||||
if p.columnCursor == 0 {
|
if p.columnCursor == 0 {
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else {
|
||||||
p.areas[p.area], cmd = p.areas[p.area].Update(prevFieldMsg{})
|
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
||||||
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
}
|
}
|
||||||
case key.Matches(msg, p.common.Keymap.Down):
|
case key.Matches(msg, p.common.Keymap.Down):
|
||||||
var cmd tea.Cmd
|
|
||||||
if p.columnCursor == 0 {
|
if p.columnCursor == 0 {
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else {
|
||||||
p.areas[p.area], cmd = p.areas[p.area].Update(nextFieldMsg{})
|
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
||||||
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,21 +197,52 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, p.common.Keymap.Back):
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
|
model, cmd := p.areas[p.area].Update(msg)
|
||||||
|
p.areas[p.area] = model.(area)
|
||||||
|
if cmd != nil {
|
||||||
|
_, ok := cmd().(input.SuppressBackMsg)
|
||||||
|
if ok {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return p, changeMode(modeNormal)
|
return p, changeMode(modeNormal)
|
||||||
|
case key.Matches(msg, p.common.Keymap.Prev):
|
||||||
|
if p.columnCursor == 0 {
|
||||||
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
|
p.areaPicker = picker.(*areaPicker)
|
||||||
|
return p, cmd
|
||||||
|
} else {
|
||||||
|
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
||||||
|
p.areas[p.area] = model.(area)
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
|
case key.Matches(msg, p.common.Keymap.Next):
|
||||||
|
if p.columnCursor == 0 {
|
||||||
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
|
p.areaPicker = picker.(*areaPicker)
|
||||||
|
return p, cmd
|
||||||
|
} else {
|
||||||
|
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
||||||
|
p.areas[p.area] = model.(area)
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
area, cmd := p.areas[p.area].Update(msg)
|
model, cmd := p.areas[p.area].Update(msg)
|
||||||
p.areas[p.area] = area
|
if p.area != 3 {
|
||||||
return p, tea.Batch(cmd, nextField())
|
p.areas[p.area] = model.(area)
|
||||||
|
return p, tea.Batch(cmd, nextField())
|
||||||
|
}
|
||||||
|
return p, cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
if p.columnCursor == 0 {
|
if p.columnCursor == 0 {
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else {
|
||||||
p.areas[p.area], cmd = p.areas[p.area].Update(msg)
|
model, cmd := p.areas[p.area].Update(msg)
|
||||||
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -198,142 +250,61 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *TaskEditorPage) View() string {
|
func (p *TaskEditorPage) View() string {
|
||||||
var focusStyle lipgloss.Style
|
var focusedStyle, blurredStyle lipgloss.Style
|
||||||
if p.mode == modeInsert {
|
if p.mode == modeInsert {
|
||||||
focusStyle = p.common.Styles.ColumnInsert
|
focusedStyle = p.common.Styles.ColumnInsert.Width(p.colWidth).Height(p.colHeight)
|
||||||
} else {
|
} else {
|
||||||
focusStyle = p.common.Styles.ColumnFocused
|
focusedStyle = p.common.Styles.ColumnFocused.Width(p.colWidth).Height(p.colHeight)
|
||||||
}
|
}
|
||||||
var picker, area string
|
blurredStyle = p.common.Styles.ColumnBlurred.Width(p.colWidth).Height(p.colHeight)
|
||||||
|
// var picker, area string
|
||||||
|
var area string
|
||||||
if p.columnCursor == 0 {
|
if p.columnCursor == 0 {
|
||||||
picker = focusStyle.Render(p.areaPicker.View())
|
// picker = focusedStyle.Render(p.areaPicker.View())
|
||||||
area = p.common.Styles.ColumnBlurred.Render(p.areas[p.area].View())
|
area = blurredStyle.Render(p.areas[p.area].View())
|
||||||
} else {
|
} else {
|
||||||
picker = p.common.Styles.ColumnBlurred.Render(p.areaPicker.View())
|
// picker = blurredStyle.Render(p.areaPicker.View())
|
||||||
area = focusStyle.Render(p.areas[p.area].View())
|
area = focusedStyle.Render(p.areas[p.area].View())
|
||||||
|
|
||||||
}
|
}
|
||||||
return lipgloss.JoinHorizontal(
|
|
||||||
|
if p.task.Uuid != "" {
|
||||||
|
area = lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Top,
|
||||||
|
area,
|
||||||
|
p.common.Styles.ColumnFocused.Render(p.common.TW.GetInformation(&p.task)),
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs := ""
|
||||||
|
for i, a := range p.areas {
|
||||||
|
if i == p.area {
|
||||||
|
tabs += p.common.Styles.Base.Bold(true).Render(fmt.Sprintf(" %s ", a.GetName()))
|
||||||
|
} else {
|
||||||
|
tabs += p.common.Styles.Base.Render(fmt.Sprintf(" %s ", a.GetName()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
picker,
|
tabs,
|
||||||
area,
|
area,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// return lipgloss.Place(p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, lipgloss.JoinHorizontal(
|
||||||
|
// lipgloss.Center,
|
||||||
|
// picker,
|
||||||
|
// area,
|
||||||
|
// ))
|
||||||
|
return lipgloss.Place(p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
// import (
|
type area interface {
|
||||||
// "fmt"
|
tea.Model
|
||||||
// "io"
|
SetCursor(c int)
|
||||||
// "log/slog"
|
GetName() string
|
||||||
// "strings"
|
}
|
||||||
// "tasksquire/common"
|
|
||||||
// "tasksquire/taskwarrior"
|
|
||||||
// "time"
|
|
||||||
|
|
||||||
// "github.com/charmbracelet/bubbles/list"
|
|
||||||
// "github.com/charmbracelet/bubbles/textinput"
|
|
||||||
// tea "github.com/charmbracelet/bubbletea"
|
|
||||||
// "github.com/charmbracelet/huh"
|
|
||||||
// "github.com/charmbracelet/lipgloss"
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type Field int
|
|
||||||
|
|
||||||
// const (
|
|
||||||
// FieldDescription Field = iota
|
|
||||||
// FieldPriority
|
|
||||||
// FieldProject
|
|
||||||
// FieldNewProject
|
|
||||||
// FieldTags
|
|
||||||
// FieldNewTags
|
|
||||||
// FieldDue
|
|
||||||
// FieldScheduled
|
|
||||||
// FieldWait
|
|
||||||
// FieldUntil
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type column int
|
|
||||||
|
|
||||||
// const (
|
|
||||||
// column1 column = iota
|
|
||||||
// column2
|
|
||||||
// column3
|
|
||||||
// )
|
|
||||||
|
|
||||||
// func changeColumn(c column) tea.Cmd {
|
|
||||||
// return func() tea.Msg {
|
|
||||||
// return changeColumnMsg(c)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type changeColumnMsg column
|
|
||||||
|
|
||||||
// type mode int
|
|
||||||
|
|
||||||
// const (
|
|
||||||
// modeNormal mode = iota
|
|
||||||
// modeInsert
|
|
||||||
// modeAddTag
|
|
||||||
// modeAddProject
|
|
||||||
// )
|
|
||||||
|
|
||||||
// type TaskEditorPage struct {
|
|
||||||
// common *common.Common
|
|
||||||
// task taskwarrior.Task
|
|
||||||
// areaList tea.Model
|
|
||||||
// mode mode
|
|
||||||
// statusline tea.Model
|
|
||||||
|
|
||||||
// // TODO: rework support for adding tags and projects
|
|
||||||
// additionalTags string
|
|
||||||
// additionalProject string
|
|
||||||
|
|
||||||
// columnCursor int
|
|
||||||
// columns []tea.Model
|
|
||||||
|
|
||||||
// areas map[area][]tea.Model
|
|
||||||
// selectedArea area
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type TaskEditorKeys struct {
|
|
||||||
// Quit key.Binding
|
|
||||||
// Up key.Binding
|
|
||||||
// Down key.Binding
|
|
||||||
// Select key.Binding
|
|
||||||
// ToggleFocus key.Binding
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
|
||||||
// p := &TaskEditorPage{
|
|
||||||
// common: common,
|
|
||||||
// task: task,
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if p.task.Uuid == "" {
|
|
||||||
// p.mode = modeInsert
|
|
||||||
// } else {
|
|
||||||
// p.mode = modeNormal
|
|
||||||
// }
|
|
||||||
|
|
||||||
// areaItems := []list.Item{
|
|
||||||
// item("Task"),
|
|
||||||
// item("Tags"),
|
|
||||||
// item("Time"),
|
|
||||||
// }
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
// p.statusline = NewStatusLine(common, p.mode)
|
|
||||||
|
|
||||||
// return p
|
|
||||||
// }
|
|
||||||
|
|
||||||
type area int
|
|
||||||
|
|
||||||
const (
|
|
||||||
areaTask area = iota
|
|
||||||
areaTags
|
|
||||||
areaTime
|
|
||||||
)
|
|
||||||
|
|
||||||
type areaPicker struct {
|
type areaPicker struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
@ -355,6 +326,9 @@ func NewAreaPicker(common *common.Common, items []string) *areaPicker {
|
|||||||
list := list.New(listItems, list.DefaultDelegate{}, 20, 50)
|
list := list.New(listItems, list.DefaultDelegate{}, 20, 50)
|
||||||
list.SetFilteringEnabled(false)
|
list.SetFilteringEnabled(false)
|
||||||
list.SetShowStatusBar(false)
|
list.SetShowStatusBar(false)
|
||||||
|
list.SetShowHelp(false)
|
||||||
|
list.SetShowPagination(false)
|
||||||
|
list.SetShowTitle(false)
|
||||||
|
|
||||||
return &areaPicker{
|
return &areaPicker{
|
||||||
common: common,
|
common: common,
|
||||||
@ -362,17 +336,18 @@ func NewAreaPicker(common *common.Common, items []string) *areaPicker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *areaPicker) Area() area {
|
func (a *areaPicker) Area() int {
|
||||||
switch a.list.SelectedItem() {
|
// switch a.list.SelectedItem() {
|
||||||
case item("Task"):
|
// case item("Task"):
|
||||||
return areaTask
|
// return areaTask
|
||||||
case item("Tags"):
|
// case item("Tags"):
|
||||||
return areaTags
|
// return areaTags
|
||||||
case item("Dates"):
|
// case item("Dates"):
|
||||||
return areaTime
|
// return areaTime
|
||||||
default:
|
// default:
|
||||||
return areaTask
|
// return areaTask
|
||||||
}
|
// }
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *areaPicker) Init() tea.Cmd {
|
func (a *areaPicker) Init() tea.Cmd {
|
||||||
@ -407,51 +382,139 @@ type taskEdit struct {
|
|||||||
fields []huh.Field
|
fields []huh.Field
|
||||||
cursor int
|
cursor int
|
||||||
|
|
||||||
newProjectName *string
|
// newProjectName *string
|
||||||
|
newAnnotation *string
|
||||||
|
udaValues map[string]*string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskEdit(common *common.Common, description *string, priority *string, project *string, priorityOptions []string, projectOptions []string) *taskEdit {
|
func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
|
||||||
newProject := ""
|
// newProject := ""
|
||||||
|
projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...)
|
||||||
|
if task.Project == "" {
|
||||||
|
task.Project = "(none)"
|
||||||
|
}
|
||||||
|
|
||||||
defaultKeymap := huh.NewDefaultKeyMap()
|
defaultKeymap := huh.NewDefaultKeyMap()
|
||||||
|
|
||||||
t := taskEdit{
|
fields := []huh.Field{
|
||||||
common: common,
|
huh.NewInput().
|
||||||
fields: []huh.Field{
|
Title("Task").
|
||||||
huh.NewInput().
|
Value(&task.Description).
|
||||||
Title("Task").
|
Validate(func(desc string) error {
|
||||||
Value(description).
|
if desc == "" {
|
||||||
Validate(func(desc string) error {
|
return fmt.Errorf("task description is required")
|
||||||
if desc == "" {
|
}
|
||||||
return fmt.Errorf("task description is required")
|
return nil
|
||||||
}
|
}).
|
||||||
return nil
|
Inline(true).
|
||||||
}).
|
Prompt(": ").
|
||||||
|
WithTheme(com.Styles.Form),
|
||||||
|
|
||||||
|
input.NewSelect(com).
|
||||||
|
Options(true, input.NewOptions(projectOptions...)...).
|
||||||
|
Title("Project").
|
||||||
|
Value(&task.Project).
|
||||||
|
WithKeyMap(defaultKeymap).
|
||||||
|
WithTheme(com.Styles.Form),
|
||||||
|
|
||||||
|
// huh.NewInput().
|
||||||
|
// Title("New Project").
|
||||||
|
// Value(&newProject).
|
||||||
|
// Inline(true).
|
||||||
|
// Prompt(": ").
|
||||||
|
// WithTheme(com.Styles.Form),
|
||||||
|
}
|
||||||
|
|
||||||
|
udaValues := make(map[string]*string)
|
||||||
|
for _, uda := range com.Udas {
|
||||||
|
switch uda.Type {
|
||||||
|
case taskwarrior.UdaTypeNumeric:
|
||||||
|
val := ""
|
||||||
|
udaValues[uda.Name] = &val
|
||||||
|
fields = append(fields, huh.NewInput().
|
||||||
|
Title(uda.Label).
|
||||||
|
Value(udaValues[uda.Name]).
|
||||||
|
Validate(taskwarrior.ValidateNumeric).
|
||||||
Inline(true).
|
Inline(true).
|
||||||
WithTheme(common.Styles.Form),
|
Prompt(": ").
|
||||||
|
WithTheme(com.Styles.Form))
|
||||||
|
case taskwarrior.UdaTypeString:
|
||||||
|
if len(uda.Values) > 0 {
|
||||||
|
var val string
|
||||||
|
values := make([]string, len(uda.Values))
|
||||||
|
for i, v := range uda.Values {
|
||||||
|
values[i] = v
|
||||||
|
if v == "" {
|
||||||
|
values[i] = "(none)"
|
||||||
|
}
|
||||||
|
if v == uda.Default {
|
||||||
|
val = values[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val == "" {
|
||||||
|
val = values[0]
|
||||||
|
}
|
||||||
|
if v, ok := task.Udas[uda.Name]; ok {
|
||||||
|
//TODO: handle uda types correctly
|
||||||
|
val = v.(string)
|
||||||
|
}
|
||||||
|
udaValues[uda.Name] = &val
|
||||||
|
|
||||||
huh.NewSelect[string]().
|
fields = append(fields, huh.NewSelect[string]().
|
||||||
Options(huh.NewOptions(priorityOptions...)...).
|
Options(huh.NewOptions(values...)...).
|
||||||
Title("Priority").
|
Title(uda.Label).
|
||||||
Key("priority").
|
Value(udaValues[uda.Name]).
|
||||||
Value(priority).
|
WithKeyMap(defaultKeymap).
|
||||||
WithKeyMap(defaultKeymap).
|
WithTheme(com.Styles.Form))
|
||||||
WithTheme(common.Styles.Form),
|
} else {
|
||||||
|
val := ""
|
||||||
|
udaValues[uda.Name] = &val
|
||||||
|
fields = append(fields, huh.NewInput().
|
||||||
|
Title(uda.Label).
|
||||||
|
Value(udaValues[uda.Name]).
|
||||||
|
Inline(true).
|
||||||
|
Prompt(": ").
|
||||||
|
WithTheme(com.Styles.Form))
|
||||||
|
}
|
||||||
|
case taskwarrior.UdaTypeDate:
|
||||||
|
val := ""
|
||||||
|
udaValues[uda.Name] = &val
|
||||||
|
fields = append(fields, huh.NewInput().
|
||||||
|
Title(uda.Label).
|
||||||
|
Value(udaValues[uda.Name]).
|
||||||
|
Validate(taskwarrior.ValidateDate).
|
||||||
|
Inline(true).
|
||||||
|
Prompt(": ").
|
||||||
|
WithTheme(com.Styles.Form))
|
||||||
|
case taskwarrior.UdaTypeDuration:
|
||||||
|
val := ""
|
||||||
|
udaValues[uda.Name] = &val
|
||||||
|
fields = append(fields, huh.NewInput().
|
||||||
|
Title(uda.Label).
|
||||||
|
Value(udaValues[uda.Name]).
|
||||||
|
Validate(taskwarrior.ValidateDuration).
|
||||||
|
Inline(true).
|
||||||
|
Prompt(": ").
|
||||||
|
WithTheme(com.Styles.Form))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
huh.NewSelect[string]().
|
newAnnotation := ""
|
||||||
Options(huh.NewOptions(projectOptions...)...).
|
fields = append(fields, huh.NewInput().
|
||||||
Title("Project").
|
Title("New Annotation").
|
||||||
Value(project).
|
Value(&newAnnotation).
|
||||||
WithKeyMap(defaultKeymap).
|
Inline(true).
|
||||||
WithTheme(common.Styles.Form),
|
Prompt(": ").
|
||||||
|
WithTheme(com.Styles.Form))
|
||||||
|
|
||||||
huh.NewInput().
|
t := taskEdit{
|
||||||
Title("New Project").
|
common: com,
|
||||||
Value(&newProject).
|
fields: fields,
|
||||||
WithTheme(common.Styles.Form),
|
|
||||||
},
|
|
||||||
|
|
||||||
newProjectName: &newProject,
|
udaValues: udaValues,
|
||||||
|
|
||||||
|
// newProjectName: &newProject,
|
||||||
|
newAnnotation: &newAnnotation,
|
||||||
}
|
}
|
||||||
|
|
||||||
t.fields[0].Focus()
|
t.fields[0].Focus()
|
||||||
@ -459,11 +522,25 @@ func NewTaskEdit(common *common.Common, description *string, priority *string, p
|
|||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t taskEdit) Init() tea.Cmd {
|
func (t *taskEdit) GetName() string {
|
||||||
|
return "Task"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *taskEdit) SetCursor(c int) {
|
||||||
|
t.fields[t.cursor].Blur()
|
||||||
|
if c < 0 {
|
||||||
|
t.cursor = len(t.fields) - 1
|
||||||
|
} else {
|
||||||
|
t.cursor = c
|
||||||
|
}
|
||||||
|
t.fields[t.cursor].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *taskEdit) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.(type) {
|
switch msg.(type) {
|
||||||
case nextFieldMsg:
|
case nextFieldMsg:
|
||||||
if t.cursor == len(t.fields)-1 {
|
if t.cursor == len(t.fields)-1 {
|
||||||
@ -490,10 +567,13 @@ func (t taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t taskEdit) View() string {
|
func (t *taskEdit) View() string {
|
||||||
views := make([]string, len(t.fields))
|
views := make([]string, len(t.fields))
|
||||||
for i, field := range t.fields {
|
for i, field := range t.fields {
|
||||||
views[i] = field.View()
|
views[i] = field.View()
|
||||||
|
if i < len(t.fields)-1 {
|
||||||
|
views[i] += "\n"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return lipgloss.JoinVertical(
|
return lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
@ -518,8 +598,8 @@ func NewTagEdit(common *common.Common, selected *[]string, options []string) *ta
|
|||||||
t := tagEdit{
|
t := tagEdit{
|
||||||
common: common,
|
common: common,
|
||||||
fields: []huh.Field{
|
fields: []huh.Field{
|
||||||
huh.NewMultiSelect[string]().
|
input.NewMultiSelect(common).
|
||||||
Options(huh.NewOptions(options...)...).
|
Options(true, input.NewOptions(options...)...).
|
||||||
// Key("tags").
|
// Key("tags").
|
||||||
Title("Tags").
|
Title("Tags").
|
||||||
Value(selected).
|
Value(selected).
|
||||||
@ -530,21 +610,34 @@ func NewTagEdit(common *common.Common, selected *[]string, options []string) *ta
|
|||||||
Title("New Tags").
|
Title("New Tags").
|
||||||
Value(&newTags).
|
Value(&newTags).
|
||||||
Inline(true).
|
Inline(true).
|
||||||
|
Prompt(": ").
|
||||||
WithTheme(common.Styles.Form),
|
WithTheme(common.Styles.Form),
|
||||||
},
|
},
|
||||||
newTagsValue: &newTags,
|
newTagsValue: &newTags,
|
||||||
}
|
}
|
||||||
|
|
||||||
t.fields[0].Focus()
|
|
||||||
|
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t tagEdit) Init() tea.Cmd {
|
func (t *tagEdit) GetName() string {
|
||||||
|
return "Tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tagEdit) SetCursor(c int) {
|
||||||
|
t.fields[t.cursor].Blur()
|
||||||
|
if c < 0 {
|
||||||
|
t.cursor = len(t.fields) - 1
|
||||||
|
} else {
|
||||||
|
t.cursor = c
|
||||||
|
}
|
||||||
|
t.fields[t.cursor].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tagEdit) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t tagEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (t *tagEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.(type) {
|
switch msg.(type) {
|
||||||
case nextFieldMsg:
|
case nextFieldMsg:
|
||||||
if t.cursor == len(t.fields)-1 {
|
if t.cursor == len(t.fields)-1 {
|
||||||
@ -598,38 +691,54 @@ func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *st
|
|||||||
Value(due).
|
Value(due).
|
||||||
Validate(taskwarrior.ValidateDate).
|
Validate(taskwarrior.ValidateDate).
|
||||||
Inline(true).
|
Inline(true).
|
||||||
|
Prompt(": ").
|
||||||
WithTheme(common.Styles.Form),
|
WithTheme(common.Styles.Form),
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Scheduled").
|
Title("Scheduled").
|
||||||
Value(scheduled).
|
Value(scheduled).
|
||||||
Validate(taskwarrior.ValidateDate).
|
Validate(taskwarrior.ValidateDate).
|
||||||
Inline(true).
|
Inline(true).
|
||||||
|
Prompt(": ").
|
||||||
WithTheme(common.Styles.Form),
|
WithTheme(common.Styles.Form),
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Wait").
|
Title("Wait").
|
||||||
Value(wait).
|
Value(wait).
|
||||||
Validate(taskwarrior.ValidateDate).
|
Validate(taskwarrior.ValidateDate).
|
||||||
Inline(true).
|
Inline(true).
|
||||||
|
Prompt(": ").
|
||||||
WithTheme(common.Styles.Form),
|
WithTheme(common.Styles.Form),
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Until").
|
Title("Until").
|
||||||
Value(until).
|
Value(until).
|
||||||
Validate(taskwarrior.ValidateDate).
|
Validate(taskwarrior.ValidateDate).
|
||||||
Inline(true).
|
Inline(true).
|
||||||
|
Prompt(": ").
|
||||||
WithTheme(common.Styles.Form),
|
WithTheme(common.Styles.Form),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
t.fields[0].Focus()
|
|
||||||
|
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t timeEdit) Init() tea.Cmd {
|
func (t *timeEdit) GetName() string {
|
||||||
|
return "Dates"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *timeEdit) SetCursor(c int) {
|
||||||
|
t.fields[t.cursor].Blur()
|
||||||
|
if c < 0 {
|
||||||
|
t.cursor = len(t.fields) - 1
|
||||||
|
} else {
|
||||||
|
t.cursor = c
|
||||||
|
}
|
||||||
|
t.fields[t.cursor].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *timeEdit) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.(type) {
|
switch msg.(type) {
|
||||||
case nextFieldMsg:
|
case nextFieldMsg:
|
||||||
if t.cursor == len(t.fields)-1 {
|
if t.cursor == len(t.fields)-1 {
|
||||||
@ -655,7 +764,7 @@ func (t timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t timeEdit) View() string {
|
func (t *timeEdit) View() string {
|
||||||
views := make([]string, len(t.fields))
|
views := make([]string, len(t.fields))
|
||||||
for i, field := range t.fields {
|
for i, field := range t.fields {
|
||||||
views[i] = field.View()
|
views[i] = field.View()
|
||||||
@ -666,6 +775,92 @@ func (t timeEdit) View() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type detailsEdit struct {
|
||||||
|
com *common.Common
|
||||||
|
vp viewport.Model
|
||||||
|
ta textarea.Model
|
||||||
|
details string
|
||||||
|
// renderer *glamour.TermRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDetailsEdit(com *common.Common, task *taskwarrior.Task) *detailsEdit {
|
||||||
|
// renderer, err := glamour.NewTermRenderer(
|
||||||
|
// // glamour.WithStandardStyle("light"),
|
||||||
|
// glamour.WithAutoStyle(),
|
||||||
|
// glamour.WithWordWrap(40),
|
||||||
|
// )
|
||||||
|
// if err != nil {
|
||||||
|
// slog.Error(err.Error())
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
vp := viewport.New(40, 30)
|
||||||
|
ta := textarea.New()
|
||||||
|
ta.SetWidth(40)
|
||||||
|
ta.SetHeight(30)
|
||||||
|
ta.ShowLineNumbers = false
|
||||||
|
ta.Focus()
|
||||||
|
if task.Udas["details"] != nil {
|
||||||
|
ta.SetValue(task.Udas["details"].(string))
|
||||||
|
}
|
||||||
|
d := detailsEdit{
|
||||||
|
com: com,
|
||||||
|
// renderer: renderer,
|
||||||
|
vp: vp,
|
||||||
|
ta: ta,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailsEdit) GetName() string {
|
||||||
|
return "Details"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailsEdit) SetCursor(c int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailsEdit) Init() tea.Cmd {
|
||||||
|
return textarea.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailsEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.(type) {
|
||||||
|
case nextFieldMsg:
|
||||||
|
return d, nextArea()
|
||||||
|
case prevFieldMsg:
|
||||||
|
return d, prevArea()
|
||||||
|
default:
|
||||||
|
var vpCmd, taCmd tea.Cmd
|
||||||
|
d.vp, vpCmd = d.vp.Update(msg)
|
||||||
|
d.ta, taCmd = d.ta.Update(msg)
|
||||||
|
return d, tea.Batch(vpCmd, taCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *detailsEdit) View() string {
|
||||||
|
return d.ta.View()
|
||||||
|
// dtls := `
|
||||||
|
// # Cool Details!
|
||||||
|
// ## Things I need
|
||||||
|
// - [ ] A thing
|
||||||
|
// - [x] Done thing
|
||||||
|
|
||||||
|
// ## People
|
||||||
|
// - pe1
|
||||||
|
// - pe2
|
||||||
|
// `
|
||||||
|
|
||||||
|
// details, err := d.renderer.Render(dtls)
|
||||||
|
// if err != nil {
|
||||||
|
// slog.Error(err.Error())
|
||||||
|
// return "Could not parse markdown"
|
||||||
|
// }
|
||||||
|
|
||||||
|
// d.vp.SetContent(details)
|
||||||
|
// return d.vp.View()
|
||||||
|
}
|
||||||
|
|
||||||
// func (p *TaskEditorPage) SetSize(width, height int) {
|
// func (p *TaskEditorPage) SetSize(width, height int) {
|
||||||
// p.common.SetSize(width, height)
|
// p.common.SetSize(width, height)
|
||||||
// }
|
// }
|
||||||
@ -792,23 +987,40 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
|
|||||||
if p.task.Project == "(none)" {
|
if p.task.Project == "(none)" {
|
||||||
p.task.Project = ""
|
p.task.Project = ""
|
||||||
}
|
}
|
||||||
if p.task.Priority == "(none)" {
|
|
||||||
p.task.Priority = ""
|
for _, uda := range p.common.Udas {
|
||||||
|
if val, ok := p.areas[0].(*taskEdit).udaValues[uda.Name]; ok {
|
||||||
|
if *val == "(none)" {
|
||||||
|
*val = ""
|
||||||
|
}
|
||||||
|
p.task.Udas[uda.Name] = *val
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if *p.areas[areaTask].(taskEdit).newProjectName != "" {
|
// if *(p.areas[0].(*taskEdit).newProjectName) != "" {
|
||||||
p.task.Project = *p.areas[areaTask].(taskEdit).newProjectName
|
// p.task.Project = *p.areas[0].(*taskEdit).newProjectName
|
||||||
}
|
|
||||||
|
|
||||||
if *p.areas[areaTags].(tagEdit).newTagsValue != "" {
|
|
||||||
p.task.Tags = append(p.task.Tags, strings.Split(*p.areas[areaTags].(tagEdit).newTagsValue, " ")...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if p.additionalProject != "" {
|
|
||||||
// p.task.Project = p.additionalProject
|
|
||||||
// }
|
// }
|
||||||
// tags := p.form.Get("tags").([]string)
|
|
||||||
// p.task.Tags = tags
|
if *(p.areas[1].(*tagEdit).newTagsValue) != "" {
|
||||||
|
newTags := strings.Split(*p.areas[1].(*tagEdit).newTagsValue, " ")
|
||||||
|
if len(newTags) > 0 {
|
||||||
|
p.task.Tags = append(p.task.Tags, newTags...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *(p.areas[0].(*taskEdit).newAnnotation) != "" {
|
||||||
|
p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{
|
||||||
|
Entry: time.Now().Format("20060102T150405Z"),
|
||||||
|
Description: *(p.areas[0].(*taskEdit).newAnnotation),
|
||||||
|
})
|
||||||
|
|
||||||
|
// p.common.TW.AddTaskAnnotation(p.task.Uuid, *p.areas[0].(*taskEdit).newAnnotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := p.task.Udas["details"]; ok || p.areas[3].(*detailsEdit).ta.Value() != "" {
|
||||||
|
p.task.Udas["details"] = p.areas[3].(*detailsEdit).ta.Value()
|
||||||
|
}
|
||||||
|
|
||||||
p.common.TW.ImportTask(&p.task)
|
p.common.TW.ImportTask(&p.task)
|
||||||
return UpdatedTasksMsg{}
|
return UpdatedTasksMsg{}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package taskwarrior
|
package taskwarrior
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
@ -13,6 +14,23 @@ const (
|
|||||||
dtformat = "20060102T150405Z"
|
dtformat = "20060102T150405Z"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type UdaType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UdaTypeString UdaType = "string"
|
||||||
|
UdaTypeDate UdaType = "date"
|
||||||
|
UdaTypeNumeric UdaType = "numeric"
|
||||||
|
UdaTypeDuration UdaType = "duration"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Uda struct {
|
||||||
|
Name string
|
||||||
|
Type UdaType
|
||||||
|
Label string
|
||||||
|
Values []string
|
||||||
|
Default string
|
||||||
|
}
|
||||||
|
|
||||||
type Annotation struct {
|
type Annotation struct {
|
||||||
Entry string `json:"entry,omitempty"`
|
Entry string `json:"entry,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
@ -22,29 +40,41 @@ func (a Annotation) String() string {
|
|||||||
return fmt.Sprintf("%s %s", a.Entry, a.Description)
|
return fmt.Sprintf("%s %s", a.Entry, a.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tasks []*Task
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
Id int64 `json:"id,omitempty"`
|
Id int64 `json:"id,omitempty"`
|
||||||
Uuid string `json:"uuid,omitempty"`
|
Uuid string `json:"uuid,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
Priority string `json:"priority"`
|
// Priority string `json:"priority"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
VirtualTags []string `json:"-"`
|
VirtualTags []string `json:"-"`
|
||||||
Depends []string `json:"depends,omitempty"`
|
Depends []string `json:"depends,omitempty"`
|
||||||
DependsIds string `json:"-"`
|
DependsIds string `json:"-"`
|
||||||
Urgency float32 `json:"urgency,omitempty"`
|
Urgency float32 `json:"urgency,omitempty"`
|
||||||
Parent string `json:"parent,omitempty"`
|
Parent string `json:"parent,omitempty"`
|
||||||
Due string `json:"due,omitempty"`
|
Due string `json:"due,omitempty"`
|
||||||
Wait string `json:"wait,omitempty"`
|
Wait string `json:"wait,omitempty"`
|
||||||
Scheduled string `json:"scheduled,omitempty"`
|
Scheduled string `json:"scheduled,omitempty"`
|
||||||
Until string `json:"until,omitempty"`
|
Until string `json:"until,omitempty"`
|
||||||
Start string `json:"start,omitempty"`
|
Start string `json:"start,omitempty"`
|
||||||
End string `json:"end,omitempty"`
|
End string `json:"end,omitempty"`
|
||||||
Entry string `json:"entry,omitempty"`
|
Entry string `json:"entry,omitempty"`
|
||||||
Modified string `json:"modified,omitempty"`
|
Modified string `json:"modified,omitempty"`
|
||||||
Recur string `json:"recur,omitempty"`
|
Recur string `json:"recur,omitempty"`
|
||||||
Annotations []Annotation `json:"annotations,omitempty"`
|
Annotations []Annotation `json:"annotations,omitempty"`
|
||||||
|
Udas map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fix pointer receiver
|
||||||
|
func NewTask() Task {
|
||||||
|
return Task{
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
Depends: make([]string, 0),
|
||||||
|
Udas: make(map[string]any),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) GetString(fieldWFormat string) string {
|
func (t *Task) GetString(fieldWFormat string) string {
|
||||||
@ -113,8 +143,8 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
return t.Project
|
return t.Project
|
||||||
|
|
||||||
case "priority":
|
// case "priority":
|
||||||
return t.Priority
|
// return t.Priority
|
||||||
|
|
||||||
case "status":
|
case "status":
|
||||||
return t.Status
|
return t.Status
|
||||||
@ -185,9 +215,16 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
return t.Recur
|
return t.Recur
|
||||||
|
|
||||||
default:
|
default:
|
||||||
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
// TODO: format according to UDA type
|
||||||
return ""
|
if val, ok := t.Udas[field]; ok {
|
||||||
|
if strVal, ok := val.(string); ok {
|
||||||
|
return strVal
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) GetDate(dateString string) time.Time {
|
func (t *Task) GetDate(dateString string) time.Time {
|
||||||
@ -223,7 +260,69 @@ func (t *Task) RemoveTag(tag string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tasks []*Task
|
func (t *Task) UnmarshalJSON(data []byte) error {
|
||||||
|
type Alias Task
|
||||||
|
task := Alias{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*t = Task(task)
|
||||||
|
|
||||||
|
m := make(map[string]any)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(m, "id")
|
||||||
|
delete(m, "uuid")
|
||||||
|
delete(m, "description")
|
||||||
|
delete(m, "project")
|
||||||
|
// delete(m, "priority")
|
||||||
|
delete(m, "status")
|
||||||
|
delete(m, "tags")
|
||||||
|
delete(m, "depends")
|
||||||
|
delete(m, "urgency")
|
||||||
|
delete(m, "parent")
|
||||||
|
delete(m, "due")
|
||||||
|
delete(m, "wait")
|
||||||
|
delete(m, "scheduled")
|
||||||
|
delete(m, "until")
|
||||||
|
delete(m, "start")
|
||||||
|
delete(m, "end")
|
||||||
|
delete(m, "entry")
|
||||||
|
delete(m, "modified")
|
||||||
|
delete(m, "recur")
|
||||||
|
delete(m, "annotations")
|
||||||
|
t.Udas = m
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias Task
|
||||||
|
task := Alias(*t)
|
||||||
|
|
||||||
|
knownFields, err := json.Marshal(task)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownMap map[string]any
|
||||||
|
if err := json.Unmarshal(knownFields, &knownMap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range t.Udas {
|
||||||
|
if value != nil && value != "" {
|
||||||
|
knownMap[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(knownMap)
|
||||||
|
}
|
||||||
|
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Name string
|
Name string
|
||||||
@ -411,3 +510,15 @@ func ValidateDate(s string) error {
|
|||||||
|
|
||||||
return fmt.Errorf("invalid date")
|
return fmt.Errorf("invalid date")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateNumeric(s string) error {
|
||||||
|
if _, err := strconv.ParseFloat(s, 64); err != nil {
|
||||||
|
return fmt.Errorf("invalid number")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDuration(s string) error {
|
||||||
|
// TODO: implement duration validation
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -89,13 +89,17 @@ type TaskWarrior interface {
|
|||||||
GetReport(report string) *Report
|
GetReport(report string) *Report
|
||||||
GetReports() Reports
|
GetReports() Reports
|
||||||
|
|
||||||
|
GetUdas() []Uda
|
||||||
|
|
||||||
GetTasks(report *Report, filter ...string) Tasks
|
GetTasks(report *Report, filter ...string) Tasks
|
||||||
AddTask(task *Task) error
|
// AddTask(task *Task) error
|
||||||
ImportTask(task *Task)
|
ImportTask(task *Task)
|
||||||
SetTaskDone(task *Task)
|
SetTaskDone(task *Task)
|
||||||
DeleteTask(task *Task)
|
DeleteTask(task *Task)
|
||||||
StartTask(task *Task)
|
StartTask(task *Task)
|
||||||
StopTask(task *Task)
|
StopTask(task *Task)
|
||||||
|
GetInformation(task *Task) string
|
||||||
|
AddTaskAnnotation(uuid string, annotation string)
|
||||||
|
|
||||||
Undo()
|
Undo()
|
||||||
}
|
}
|
||||||
@ -281,10 +285,18 @@ func (ts *TaskSquire) GetTags() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tags := make([]string, 0)
|
tags := make([]string, 0)
|
||||||
|
tagSet := make(map[string]struct{})
|
||||||
|
|
||||||
for _, tag := range strings.Split(string(output), "\n") {
|
for _, tag := range strings.Split(string(output), "\n") {
|
||||||
if _, ok := virtualTags[tag]; !ok && tag != "" {
|
if _, ok := virtualTags[tag]; !ok && tag != "" {
|
||||||
tags = append(tags, tag)
|
tags = append(tags, tag)
|
||||||
|
tagSet[tag] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range strings.Split(ts.config.Get("uda.tasksquire.tags.default"), ",") {
|
||||||
|
if _, ok := tagSet[tag]; !ok {
|
||||||
|
tags = append(tags, tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,6 +319,45 @@ func (ts *TaskSquire) GetReports() Reports {
|
|||||||
return ts.reports
|
return ts.reports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ts *TaskSquire) GetUdas() []Uda {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, "_udas")...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting config:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
udas := make([]Uda, 0)
|
||||||
|
for _, uda := range strings.Split(string(output), "\n") {
|
||||||
|
if uda != "" {
|
||||||
|
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
||||||
|
if udatype == "" {
|
||||||
|
slog.Error(fmt.Sprintf("UDA type not found: %s", uda))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
label := ts.config.Get(fmt.Sprintf("uda.%s.label", uda))
|
||||||
|
values := strings.Split(ts.config.Get(fmt.Sprintf("uda.%s.values", uda)), ",")
|
||||||
|
def := ts.config.Get(fmt.Sprintf("uda.%s.default", uda))
|
||||||
|
|
||||||
|
uda := Uda{
|
||||||
|
Name: uda,
|
||||||
|
Label: label,
|
||||||
|
Type: udatype,
|
||||||
|
Values: values,
|
||||||
|
Default: def,
|
||||||
|
}
|
||||||
|
|
||||||
|
udas = append(udas, uda)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return udas
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) SetContext(context *Context) error {
|
func (ts *TaskSquire) SetContext(context *Context) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
@ -328,42 +379,42 @@ func (ts *TaskSquire) SetContext(context *Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) AddTask(task *Task) error {
|
// func (ts *TaskSquire) AddTask(task *Task) error {
|
||||||
ts.mutex.Lock()
|
// ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
// defer ts.mutex.Unlock()
|
||||||
|
|
||||||
addArgs := []string{"add"}
|
// addArgs := []string{"add"}
|
||||||
|
|
||||||
if task.Description == "" {
|
// if task.Description == "" {
|
||||||
slog.Error("Task description is required")
|
// slog.Error("Task description is required")
|
||||||
return nil
|
// return nil
|
||||||
} else {
|
// } else {
|
||||||
addArgs = append(addArgs, task.Description)
|
// addArgs = append(addArgs, task.Description)
|
||||||
}
|
// }
|
||||||
if task.Priority != "" && task.Priority != "(none)" {
|
// if task.Priority != "" && task.Priority != "(none)" {
|
||||||
addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
|
// addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
|
||||||
}
|
// }
|
||||||
if task.Project != "" && task.Project != "(none)" {
|
// if task.Project != "" && task.Project != "(none)" {
|
||||||
addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
|
// addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
|
||||||
}
|
// }
|
||||||
if task.Tags != nil {
|
// if task.Tags != nil {
|
||||||
for _, tag := range task.Tags {
|
// for _, tag := range task.Tags {
|
||||||
addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
|
// addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
if task.Due != "" {
|
// if task.Due != "" {
|
||||||
addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
|
// addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
|
||||||
}
|
// }
|
||||||
|
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
|
// cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
|
||||||
err := cmd.Run()
|
// err := cmd.Run()
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
slog.Error("Failed adding task:", err)
|
// slog.Error("Failed adding task:", err)
|
||||||
}
|
// }
|
||||||
|
|
||||||
// TODO remove error?
|
// // TODO remove error?
|
||||||
return nil
|
// return nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
func (ts *TaskSquire) ImportTask(task *Task) {
|
func (ts *TaskSquire) ImportTask(task *Task) {
|
||||||
@ -371,7 +422,6 @@ func (ts *TaskSquire) ImportTask(task *Task) {
|
|||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
tasks, err := json.Marshal(Tasks{task})
|
tasks, err := json.Marshal(Tasks{task})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed marshalling task:", err)
|
slog.Error("Failed marshalling task:", err)
|
||||||
}
|
}
|
||||||
@ -440,6 +490,31 @@ func (ts *TaskSquire) StopTask(task *Task) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ts *TaskSquire) GetInformation(task *Task) string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting task information:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed adding annotation:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *TaskSquire) extractConfig() *TWConfig {
|
func (ts *TaskSquire) extractConfig() *TWConfig {
|
||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|||||||
Binary file not shown.
24
test/taskrc
24
test/taskrc
@ -1,6 +1,30 @@
|
|||||||
include light-256.theme
|
include light-256.theme
|
||||||
|
|
||||||
|
uda.priority.values=H,M,,L
|
||||||
|
|
||||||
context.test.read=+test
|
context.test.read=+test
|
||||||
context.test.write=+test
|
context.test.write=+test
|
||||||
context.home.read=+home
|
context.home.read=+home
|
||||||
context.home.write=+home
|
context.home.write=+home
|
||||||
|
|
||||||
|
uda.testuda.type=string
|
||||||
|
uda.testuda.label=Testuda
|
||||||
|
uda.testuda.values=eins,zwei,drei
|
||||||
|
uda.testuda.default=eins
|
||||||
|
|
||||||
|
uda.testuda2.type=numeric
|
||||||
|
uda.testuda2.label=TESTUDA2
|
||||||
|
|
||||||
|
uda.testuda3.type=date
|
||||||
|
uda.testuda3.label=Ttttuda
|
||||||
|
|
||||||
|
uda.testuda4.type=duration
|
||||||
|
uda.testuda4.label=TtttudaDURUD
|
||||||
|
|
||||||
|
report.next.columns=id,testuda,start.age,entry.age,depends,priority,project,tags,recur,scheduled.countdown,due.relative,until.remaining,description,urgency
|
||||||
|
report.next.context=1
|
||||||
|
report.next.description=Most urgent tasks
|
||||||
|
report.next.filter=status:pending -WAITING
|
||||||
|
report.next.labels=ID,UDA,Active,Age,Deps,P,Project,Tag,Recur,S,Due,Until,Description,Urg
|
||||||
|
report.next.sort=urgency-
|
||||||
|
uda.tasksquire.use_details=true
|
||||||
|
|||||||
Reference in New Issue
Block a user