Add timestamp editor

This commit is contained in:
Martin Pander
2026-02-02 10:55:47 +01:00
parent 7032d0fa54
commit fc8e9481c3
19 changed files with 922 additions and 113 deletions

View File

@ -26,7 +26,7 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
}
selected := common.TW.GetActiveContext().Name
itemProvider := func() []list.Item {
contexts := common.TW.GetContexts()
options := make([]string, 0)
@ -141,4 +141,4 @@ func (p *ContextPickerPage) View() string {
)
}
type UpdateContextMsg *taskwarrior.Context
type UpdateContextMsg *taskwarrior.Context

View File

@ -10,7 +10,7 @@ import (
type MainPage struct {
common *common.Common
activePage common.Component
taskPage common.Component
timePage common.Component
}
@ -22,7 +22,7 @@ func NewMainPage(common *common.Common) *MainPage {
m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
m.timePage = NewTimePage(common)
m.activePage = m.taskPage
return m
@ -39,7 +39,8 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.common.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
if key.Matches(msg, m.common.Keymap.Next) {
// Only handle tab key for page switching when at the top level (no subpages active)
if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
if m.activePage == m.taskPage {
m.activePage = m.timePage
} else {

View File

@ -133,4 +133,4 @@ func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
return nil
}
type UpdateProjectMsg string
type UpdateProjectMsg string

View File

@ -45,10 +45,6 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
taskTable: table.New(com),
}
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
p.subpage.Init()
p.common.PushPage(p)
return p
}

View File

@ -129,4 +129,4 @@ func (p *ReportPickerPage) View() string {
)
}
type UpdateReportMsg *taskwarrior.Report
type UpdateReportMsg *taskwarrior.Report

View File

@ -8,6 +8,7 @@ import (
"time"
"tasksquire/components/input"
"tasksquire/components/timestampeditor"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
@ -676,45 +677,56 @@ func (t tagEdit) View() string {
type timeEdit struct {
common *common.Common
fields []huh.Field
fields []*timestampeditor.TimestampEditor
cursor int
// Store task field pointers to update them
due *string
scheduled *string
wait *string
until *string
}
func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit {
// defaultKeymap := huh.NewDefaultKeyMap()
// Create timestamp editors for each date field
dueEditor := timestampeditor.New(common).
Title("Due").
Description("When the task is due").
ValueFromString(*due)
scheduledEditor := timestampeditor.New(common).
Title("Scheduled").
Description("When to start working on the task").
ValueFromString(*scheduled)
waitEditor := timestampeditor.New(common).
Title("Wait").
Description("Hide task until this date").
ValueFromString(*wait)
untilEditor := timestampeditor.New(common).
Title("Until").
Description("Task expires after this date").
ValueFromString(*until)
t := timeEdit{
common: common,
fields: []huh.Field{
huh.NewInput().
Title("Due").
Value(due).
Validate(taskwarrior.ValidateDate).
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Scheduled").
Value(scheduled).
Validate(taskwarrior.ValidateDate).
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Wait").
Value(wait).
Validate(taskwarrior.ValidateDate).
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Until").
Value(until).
Validate(taskwarrior.ValidateDate).
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
fields: []*timestampeditor.TimestampEditor{
dueEditor,
scheduledEditor,
waitEditor,
untilEditor,
},
due: due,
scheduled: scheduled,
wait: wait,
until: until,
}
// Focus the first field
if len(t.fields) > 0 {
t.fields[0].Focus()
}
return &t
@ -725,12 +737,21 @@ func (t *timeEdit) GetName() string {
}
func (t *timeEdit) SetCursor(c int) {
if len(t.fields) == 0 {
return
}
// Blur the current field
t.fields[t.cursor].Blur()
// Set new cursor position
if c < 0 {
t.cursor = len(t.fields) - 1
} else {
t.cursor = c
}
// Focus the new field
t.fields[t.cursor].Focus()
}
@ -739,42 +760,71 @@ func (t *timeEdit) Init() tea.Cmd {
}
func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
switch msg := msg.(type) {
case nextFieldMsg:
if t.cursor == len(t.fields)-1 {
// Update task field before moving to next area
t.syncToTaskFields()
t.fields[t.cursor].Blur()
return t, nextArea()
}
t.fields[t.cursor].Blur()
t.cursor++
t.fields[t.cursor].Focus()
return t, nil
case prevFieldMsg:
if t.cursor == 0 {
// Update task field before moving to previous area
t.syncToTaskFields()
t.fields[t.cursor].Blur()
return t, prevArea()
}
t.fields[t.cursor].Blur()
t.cursor--
t.fields[t.cursor].Focus()
return t, nil
default:
field, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = field.(huh.Field)
// Update the current timestamp editor
model, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = model.(*timestampeditor.TimestampEditor)
return t, cmd
}
return t, nil
}
func (t *timeEdit) View() string {
views := make([]string, len(t.fields))
for i, field := range t.fields {
views[i] = field.View()
if i < len(t.fields)-1 {
views[i] += "\n"
}
}
return lipgloss.JoinVertical(
lipgloss.Left,
views...,
)
}
// syncToTaskFields converts the timestamp editor values back to task field strings
func (t *timeEdit) syncToTaskFields() {
// Update the task fields with values from the timestamp editors
// GetValueString() returns empty string for unset timestamps
if len(t.fields) > 0 {
*t.due = t.fields[0].GetValueString()
}
if len(t.fields) > 1 {
*t.scheduled = t.fields[1].GetValueString()
}
if len(t.fields) > 2 {
*t.wait = t.fields[2].GetValueString()
}
if len(t.fields) > 3 {
*t.until = t.fields[3].GetValueString()
}
}
type detailsEdit struct {
com *common.Common
vp viewport.Model
@ -1009,6 +1059,9 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
}
}
// Sync timestamp fields from the timeEdit area (area 2)
p.areas[2].(*timeEdit).syncToTaskFields()
if *(p.areas[0].(*taskEdit).newAnnotation) != "" {
p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{
Entry: time.Now().Format("20060102T150405Z"),

View File

@ -4,61 +4,65 @@ import (
"log/slog"
"strings"
"tasksquire/common"
"tasksquire/components/timestampeditor"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type TimeEditorPage struct {
common *common.Common
interval *timewarrior.Interval
form *huh.Form
startStr string
endStr string
tagsStr string
// Fields
startEditor *timestampeditor.TimestampEditor
endEditor *timestampeditor.TimestampEditor
tagsInput textinput.Model
adjust bool
// State
currentField int
totalFields int
}
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
p := &TimeEditorPage{
common: com,
interval: interval,
startStr: interval.Start,
endStr: interval.End,
tagsStr: formatTags(interval.Tags),
}
// Create start timestamp editor
startEditor := timestampeditor.New(com).
Title("Start").
ValueFromString(interval.Start)
p.form = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Start").
Value(&p.startStr).
Validate(func(s string) error {
return timewarrior.ValidateDate(s)
}),
huh.NewInput().
Title("End").
Value(&p.endStr).
Validate(func(s string) error {
if s == "" {
return nil // End can be empty (active)
}
return timewarrior.ValidateDate(s)
}),
huh.NewInput().
Title("Tags").
Value(&p.tagsStr).
Description("Space separated, use \"\" for tags with spaces"),
),
).WithTheme(com.Styles.Form)
// Create end timestamp editor
endEditor := timestampeditor.New(com).
Title("End").
ValueFromString(interval.End)
// Create tags input
tagsInput := textinput.New()
tagsInput.Placeholder = "Space separated, use \"\" for tags with spaces"
tagsInput.SetValue(formatTags(interval.Tags))
tagsInput.Width = 50
p := &TimeEditorPage{
common: com,
interval: interval,
startEditor: startEditor,
endEditor: endEditor,
tagsInput: tagsInput,
adjust: true, // Enable :adjust by default
currentField: 0,
totalFields: 4, // Updated to include adjust field
}
return p
}
func (p *TimeEditorPage) Init() tea.Cmd {
return p.form.Init()
// Focus the first field
p.currentField = 0
return p.startEditor.Focus()
}
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -66,41 +70,165 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if key.Matches(msg, p.common.Keymap.Back) {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
case key.Matches(msg, p.common.Keymap.Ok):
// Save and exit
p.saveInterval()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
case key.Matches(msg, p.common.Keymap.Next):
// Move to next field
p.blurCurrentField()
p.currentField = (p.currentField + 1) % p.totalFields
cmds = append(cmds, p.focusCurrentField())
case key.Matches(msg, p.common.Keymap.Prev):
// Move to previous field
p.blurCurrentField()
p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields
cmds = append(cmds, p.focusCurrentField())
}
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
}
form, cmd := p.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
p.form = f
// Update the currently focused field
var cmd tea.Cmd
switch p.currentField {
case 0:
var model tea.Model
model, cmd = p.startEditor.Update(msg)
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
p.startEditor = editor
}
case 1:
var model tea.Model
model, cmd = p.endEditor.Update(msg)
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
p.endEditor = editor
}
case 2:
p.tagsInput, cmd = p.tagsInput.Update(msg)
case 3:
// Handle adjust toggle with space/enter
if msg, ok := msg.(tea.KeyMsg); ok {
if msg.String() == " " || msg.String() == "enter" {
p.adjust = !p.adjust
}
}
}
cmds = append(cmds, cmd)
if p.form.State == huh.StateCompleted {
p.saveInterval()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
// Return with a command to refresh the intervals
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
}
return p, tea.Batch(cmds...)
}
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
switch p.currentField {
case 0:
return p.startEditor.Focus()
case 1:
return p.endEditor.Focus()
case 2:
p.tagsInput.Focus()
return nil
case 3:
// Adjust checkbox doesn't need focus action
return nil
}
return nil
}
func (p *TimeEditorPage) blurCurrentField() {
switch p.currentField {
case 0:
p.startEditor.Blur()
case 1:
p.endEditor.Blur()
case 2:
p.tagsInput.Blur()
case 3:
// Adjust checkbox doesn't need blur action
}
}
func (p *TimeEditorPage) View() string {
return p.form.View()
var sections []string
// Title
titleStyle := p.common.Styles.Form.Focused.Title
sections = append(sections, titleStyle.Render("Edit Time Interval"))
sections = append(sections, "")
// Start editor
sections = append(sections, p.startEditor.View())
sections = append(sections, "")
// End editor
sections = append(sections, p.endEditor.View())
sections = append(sections, "")
// Tags input
tagsLabelStyle := p.common.Styles.Form.Focused.Title
tagsLabel := tagsLabelStyle.Render("Tags")
if p.currentField == 2 {
sections = append(sections, tagsLabel)
sections = append(sections, p.tagsInput.View())
descStyle := p.common.Styles.Form.Focused.Description
sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces"))
} else {
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
sections = append(sections, blurredLabelStyle.Render("Tags"))
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.Value()))
}
sections = append(sections, "")
sections = append(sections, "")
// Adjust checkbox
adjustLabelStyle := p.common.Styles.Form.Focused.Title
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
var checkbox string
if p.adjust {
checkbox = "[X]"
} else {
checkbox = "[ ]"
}
if p.currentField == 3 {
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
sections = append(sections, adjustLabel)
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
descStyle := p.common.Styles.Form.Focused.Description
sections = append(sections, descStyle.Render("Press space to toggle"))
} else {
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
sections = append(sections, blurredLabelStyle.Render("Adjust overlaps"))
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(checkbox+" Auto-adjust overlapping intervals"))
}
sections = append(sections, "")
sections = append(sections, "")
// Help text
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel"))
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func (p *TimeEditorPage) SetSize(width, height int) {
@ -117,13 +245,13 @@ func (p *TimeEditorPage) saveInterval() {
}
}
p.interval.Start = p.startStr
p.interval.End = p.endStr
p.interval.Start = p.startEditor.GetValueString()
p.interval.End = p.endEditor.GetValueString()
// Parse tags
p.interval.Tags = parseTags(p.tagsStr)
p.interval.Tags = parseTags(p.tagsInput.Value())
err := p.common.TimeW.ModifyInterval(p.interval)
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
if err != nil {
slog.Error("Failed to modify interval", "err", err)
}
@ -164,5 +292,3 @@ func formatTags(tags []string) string {
}
return strings.Join(formatted, " ")
}

View File

@ -24,7 +24,7 @@ func NewTimePage(com *common.Common) *TimePage {
p := &TimePage{
common: com,
}
p.populateTable(timewarrior.Intervals{})
return p
}