Compare commits

...

10 Commits

Author SHA1 Message Date
82c41a22d2 Update db 2024-06-24 16:47:54 +02:00
73d51b956a Fix pickers; Add new select option 2024-06-24 16:36:11 +02:00
fac7ff81dd Add tasks 2024-06-12 07:42:54 +02:00
0d55a3b119 Add new options in multiselect 2024-06-11 21:48:13 +02:00
c660b6cbb1 Update tasks 2024-06-09 21:55:45 +02:00
98d2d041d6 Add details editing 2024-06-09 21:46:39 +02:00
bafd8958d4 Handle UDAs for editing; Fix layout; Add annotations 2024-06-09 17:55:56 +02:00
3e1cb9d1bc Next/Prev task edit 2024-06-05 16:29:47 +02:00
0572763e31 Fixes 2024-06-04 16:45:57 +02:00
9aa7b04b98 Fix UDA colors 2024-05-31 13:40:49 +02:00
20 changed files with 2313 additions and 526 deletions

View File

@ -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
} }

View File

@ -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(

View File

@ -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 {

View 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{}
}
}

View 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
View 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
}

View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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)

View File

@ -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 {

View File

@ -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)
})
}

View File

@ -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 {

View File

@ -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

View File

@ -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 {

View File

@ -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{}
} }

View File

@ -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
}

View File

@ -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.

View File

@ -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