github.com/wtfutil/wtf@v0.43.0/modules/todo/widget.go (about)

     1  package todo
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"regexp"
     7  	"strconv"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/gdamore/tcell/v2"
    12  	"github.com/rivo/tview"
    13  	"github.com/wtfutil/wtf/cfg"
    14  	"github.com/wtfutil/wtf/checklist"
    15  	"github.com/wtfutil/wtf/utils"
    16  	"github.com/wtfutil/wtf/view"
    17  	"github.com/wtfutil/wtf/wtf"
    18  	"gopkg.in/yaml.v2"
    19  )
    20  
    21  const (
    22  	modalHeight = 7
    23  	modalWidth  = 80
    24  	offscreen   = -1000
    25  )
    26  
    27  // A Widget represents a Todo widget
    28  type Widget struct {
    29  	filePath      string
    30  	list          checklist.Checklist
    31  	pages         *tview.Pages
    32  	settings      *Settings
    33  	showTagPrefix string
    34  	showFilter    string
    35  	tviewApp      *tview.Application
    36  	Error         string
    37  
    38  	view.ScrollableWidget
    39  
    40  	// redrawChan chan bool
    41  }
    42  
    43  // NewWidget creates a new instance of a widget
    44  func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget {
    45  	widget := Widget{
    46  		ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common),
    47  
    48  		tviewApp:      tviewApp,
    49  		settings:      settings,
    50  		filePath:      settings.filePath,
    51  		showTagPrefix: "",
    52  		list:          checklist.NewChecklist(settings.Sigils.Checkbox.Checked, settings.Sigils.Checkbox.Unchecked),
    53  		pages:         pages,
    54  
    55  		// redrawChan: redrawChan,
    56  	}
    57  
    58  	widget.init()
    59  
    60  	widget.initializeKeyboardControls()
    61  
    62  	widget.View.SetRegions(true)
    63  	widget.View.SetScrollable(true)
    64  
    65  	widget.SetRenderFunction(widget.display)
    66  
    67  	return &widget
    68  }
    69  
    70  /* -------------------- Exported Functions -------------------- */
    71  
    72  // SelectedItem returns the currently-selected checklist item or nil if no item is selected
    73  func (widget *Widget) SelectedItem() *checklist.ChecklistItem {
    74  	var selectedItem *checklist.ChecklistItem
    75  	if widget.isItemSelected() {
    76  		selectedItem = widget.list.Items[widget.Selected]
    77  	}
    78  
    79  	return selectedItem
    80  }
    81  
    82  // Refresh updates the data for this widget and displays it onscreen
    83  func (widget *Widget) Refresh() {
    84  	widget.Error = ""
    85  	err := widget.load()
    86  	if err != nil {
    87  		widget.Error = err.Error()
    88  	}
    89  	widget.display()
    90  }
    91  
    92  func (widget *Widget) SetList(list checklist.Checklist) {
    93  	widget.list = list
    94  }
    95  
    96  /* -------------------- Unexported Functions -------------------- */
    97  
    98  func (widget *Widget) init() {
    99  	_, err := cfg.CreateFile(widget.filePath)
   100  	if err != nil {
   101  		return
   102  	}
   103  }
   104  
   105  // isItemSelected returns whether any item of the todo is selected or not
   106  func (widget *Widget) isItemSelected() bool {
   107  	return widget.Selected >= 0 && widget.Selected < len(widget.list.Items)
   108  }
   109  
   110  // Loads the todo list from3 Yaml file
   111  func (widget *Widget) load() error {
   112  	confDir, _ := cfg.WtfConfigDir()
   113  	filePath := fmt.Sprintf("%s/%s", confDir, widget.filePath)
   114  
   115  	fileData, err := utils.ReadFileBytes(filePath)
   116  
   117  	if err != nil {
   118  		return err
   119  	}
   120  
   121  	err = yaml.Unmarshal(fileData, &widget.list)
   122  	if err != nil {
   123  		return err
   124  	}
   125  
   126  	// do initial sort based on dates to make sure everything is correct
   127  	if widget.settings.parseDates {
   128  		i := 0
   129  		for i < widget.list.Len() {
   130  			for {
   131  				newIndex := widget.placeItemBasedOnDate(i)
   132  				if newIndex == i {
   133  					break
   134  				}
   135  			}
   136  			i += 1
   137  		}
   138  	}
   139  
   140  	widget.ScrollableWidget.SetItemCount(len(widget.list.Items))
   141  	widget.setItemChecks()
   142  	return nil
   143  }
   144  
   145  func (widget *Widget) newItem() {
   146  	widget.processFormInput("New Todo:", "", func(t string) {
   147  		text, date, tags := widget.getTextComponents(t)
   148  
   149  		widget.list.Add(false, date, tags, text, widget.settings.newPos)
   150  		widget.SetItemCount(len(widget.list.Items))
   151  		if widget.settings.parseDates {
   152  			if widget.settings.newPos == "first" {
   153  				widget.placeItemBasedOnDate(0)
   154  			} else {
   155  				widget.placeItemBasedOnDate(widget.list.Len() - 1)
   156  			}
   157  		}
   158  		widget.persist()
   159  	})
   160  }
   161  
   162  func (widget *Widget) getTextComponents(text string) (string, *time.Time, []string) {
   163  	var date *time.Time = nil
   164  	if widget.settings.parseDates {
   165  		text, date = widget.getTextAndDate(text)
   166  	}
   167  
   168  	tags := make([]string, 0)
   169  	if widget.settings.parseTags {
   170  		text, tags = getTodoTags(text)
   171  	}
   172  
   173  	text = strings.TrimSpace(text)
   174  	return text, date, tags
   175  }
   176  
   177  func getTodoTags(text string) (string, []string) {
   178  	tags := make([]string, 0)
   179  	r, _ := regexp.Compile(`(?i)(^|\s)#[a-z0-9]+`)
   180  	matches := r.FindAllString(text, -1)
   181  
   182  	for _, tag := range matches {
   183  		tag = strings.TrimSpace(tag)
   184  		suffix := " "
   185  		if strings.HasSuffix(text, tag) {
   186  			suffix = ""
   187  		}
   188  		text = strings.Replace(text, tag+suffix, "", 1)
   189  		tags = append(tags, tag[1:])
   190  	}
   191  
   192  	return text, tags
   193  }
   194  
   195  type PatternDuration struct {
   196  	pattern string
   197  	d       int
   198  	m       int
   199  	y       int
   200  }
   201  
   202  func (widget *Widget) getTextAndDate(text string) (string, *time.Time) {
   203  	now := time.Now()
   204  	textLower := strings.ToLower(text)
   205  	// check for "in X days/weeks/months/years" pattern
   206  	r, _ := regexp.Compile("(?i)^in [0-9]+ (day|week|month|year)(s|)")
   207  	match := r.FindString(text)
   208  	if len(match) > 0 && len(text) > len(match) {
   209  		parts := strings.Split(text, " ")
   210  		n, _ := strconv.Atoi(parts[1])
   211  		unit := parts[2][:1]
   212  		var target time.Time
   213  		if unit == "d" {
   214  			target = now.AddDate(0, 0, n)
   215  		} else if unit == "w" {
   216  			target = now.AddDate(0, 0, 7*n)
   217  		} else if unit == "m" {
   218  			target = now.AddDate(0, n, 0)
   219  		} else {
   220  			target = now.AddDate(n, 0, 0)
   221  		}
   222  		return text[len(match):], &target
   223  	}
   224  
   225  	// check for "today / tomorrow / next X"
   226  	patterns := [...]PatternDuration{
   227  		{pattern: "today", d: 0, m: 0, y: 0},
   228  		{pattern: "tomorrow", d: 1, m: 0, y: 0},
   229  		{pattern: "next week", d: 7, m: 0, y: 0},
   230  		{pattern: "next month", d: 0, m: 1, y: 0},
   231  		{pattern: "next year", d: 0, m: 0, y: 1},
   232  	}
   233  	for _, pd := range patterns {
   234  		if strings.HasPrefix(textLower, pd.pattern) && len(text) > len(pd.pattern) {
   235  			date := now.AddDate(pd.y, pd.m, pd.d)
   236  			return text[len(pd.pattern):], &date
   237  		}
   238  	}
   239  
   240  	// check for "next X" where X is name of a day (monday, etc)
   241  	if strings.HasPrefix(textLower, "next") {
   242  		parts := strings.Split(textLower, " ")
   243  		if parts[0] == "next" && len(parts) > 2 {
   244  			for i, d := range []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"} {
   245  				if strings.ToLower(parts[1]) == d {
   246  					date := now.AddDate(0, 0, int(now.Weekday())+7-i)
   247  					return text[len(d)+5:], &date
   248  				}
   249  			}
   250  		}
   251  	}
   252  
   253  	// check for YYYY-MM-DD prefix
   254  	if len(text) > 10 {
   255  		date, err := time.Parse("2006-01-02", text[:10])
   256  		if err == nil {
   257  			return text[10:], &date
   258  		}
   259  	}
   260  
   261  	// check for MM-DD prefix
   262  	if len(text) > 5 {
   263  		date, err := time.Parse("2006-01-02", strconv.FormatInt(int64(now.Year()), 10)+"-"+text[:5])
   264  		if err == nil {
   265  			return text[5:], &date
   266  		}
   267  	}
   268  
   269  	return text, nil
   270  }
   271  
   272  // persist writes the todo list to Yaml file
   273  func (widget *Widget) persist() {
   274  	confDir, _ := cfg.WtfConfigDir()
   275  	filePath := fmt.Sprintf("%s/%s", confDir, widget.filePath)
   276  
   277  	fileData, _ := yaml.Marshal(&widget.list)
   278  
   279  	err := os.WriteFile(filePath, fileData, 0644)
   280  
   281  	if err != nil {
   282  		panic(err)
   283  	}
   284  }
   285  
   286  // setItemChecks rolls through the checklist and ensures that all checklist
   287  // items have the correct checked/unchecked icon per the user's preferences
   288  func (widget *Widget) setItemChecks() {
   289  	for _, item := range widget.list.Items {
   290  		item.CheckedIcon = widget.settings.checked
   291  		item.UncheckedIcon = widget.settings.unchecked
   292  	}
   293  }
   294  
   295  // updateSelected sets the text of the currently-selected item to the provided text
   296  func (widget *Widget) updateSelected() {
   297  	if !widget.isItemSelected() {
   298  		return
   299  	}
   300  
   301  	widget.processFormInput("Edit:", widget.SelectedItem().EditText(), func(t string) {
   302  		text, date, tags := widget.getTextComponents(t)
   303  
   304  		widget.updateSelectedItem(text, date, tags)
   305  		if widget.settings.parseDates {
   306  			widget.Selected = widget.placeItemBasedOnDate(widget.Selected)
   307  		}
   308  		widget.persist()
   309  	})
   310  }
   311  
   312  // processFormInput is a helper function that creates a form and calls onSave on the received input
   313  func (widget *Widget) processFormInput(prompt string, initValue string, onSave func(string)) {
   314  	form := widget.modalForm(prompt, initValue)
   315  
   316  	saveFctn := func() {
   317  		onSave(form.GetFormItem(0).(*tview.InputField).GetText())
   318  
   319  		widget.pages.RemovePage("modal")
   320  		widget.tviewApp.SetFocus(widget.View)
   321  		widget.display()
   322  	}
   323  
   324  	widget.addButtons(form, saveFctn)
   325  	widget.modalFocus(form)
   326  
   327  	// Tell the app to force redraw the screen
   328  	widget.Base.RedrawChan <- true
   329  }
   330  
   331  // updateSelectedItem update the text of the selected item.
   332  func (widget *Widget) updateSelectedItem(text string, date *time.Time, tags []string) {
   333  	selectedItem := widget.SelectedItem()
   334  	if selectedItem == nil {
   335  		return
   336  	}
   337  
   338  	selectedItem.Text = text
   339  	selectedItem.Date = date
   340  	selectedItem.Tags = tags
   341  }
   342  
   343  func (widget *Widget) placeItemBasedOnDate(index int) int {
   344  	// potentially move todo up
   345  	for index > 0 && widget.todoDateIsEarlier(index, index-1) {
   346  		widget.list.Swap(index, index-1)
   347  		index -= 1
   348  	}
   349  	// potentially move todo down
   350  	for index < widget.list.Len()-1 && widget.todoDateIsEarlier(index+1, index) {
   351  		widget.list.Swap(index, index+1)
   352  		index += 1
   353  	}
   354  	return index
   355  }
   356  
   357  func (widget *Widget) todoDateIsEarlier(i, j int) bool {
   358  	if widget.list.Items[i].Date == nil && widget.list.Items[j].Date == nil {
   359  		return false
   360  	}
   361  	defaultVal := getNowDate().AddDate(0, 0, widget.settings.undatedAsDays)
   362  	if widget.list.Items[i].Date == nil {
   363  		return defaultVal.Before(*widget.list.Items[j].Date)
   364  	} else if widget.list.Items[j].Date == nil {
   365  		return widget.list.Items[i].Date.Before(defaultVal)
   366  	} else {
   367  		return widget.list.Items[i].Date.Before(*widget.list.Items[j].Date)
   368  	}
   369  }
   370  
   371  /* -------------------- Modal Form -------------------- */
   372  
   373  func (widget *Widget) addButtons(form *tview.Form, saveFctn func()) {
   374  	widget.addSaveButton(form, saveFctn)
   375  	widget.addCancelButton(form)
   376  }
   377  
   378  func (widget *Widget) addCancelButton(form *tview.Form) {
   379  	cancelFn := func() {
   380  		widget.pages.RemovePage("modal")
   381  		widget.tviewApp.SetFocus(widget.View)
   382  		widget.display()
   383  	}
   384  
   385  	form.AddButton("Cancel", cancelFn)
   386  	form.SetCancelFunc(cancelFn)
   387  }
   388  
   389  func (widget *Widget) addSaveButton(form *tview.Form, fctn func()) {
   390  	form.AddButton("Save", fctn)
   391  }
   392  
   393  func (widget *Widget) modalFocus(form *tview.Form) {
   394  	frame := widget.modalFrame(form)
   395  	widget.pages.AddPage("modal", frame, false, true)
   396  	widget.tviewApp.SetFocus(frame)
   397  
   398  	// Tell the app to force redraw the screen
   399  	widget.Base.RedrawChan <- true
   400  }
   401  
   402  func (widget *Widget) modalForm(lbl, text string) *tview.Form {
   403  	form := tview.NewForm()
   404  	form.SetFieldBackgroundColor(wtf.ColorFor(widget.settings.Colors.Background))
   405  	form.SetButtonsAlign(tview.AlignCenter)
   406  	form.SetButtonTextColor(wtf.ColorFor(widget.settings.Colors.Text))
   407  
   408  	form.AddInputField(lbl, text, 60, nil, nil)
   409  
   410  	return form
   411  }
   412  
   413  func (widget *Widget) modalFrame(form *tview.Form) *tview.Frame {
   414  	frame := tview.NewFrame(form)
   415  	frame.SetBorders(0, 0, 0, 0, 0, 0)
   416  	frame.SetRect(offscreen, offscreen, modalWidth, modalHeight)
   417  	frame.SetBorder(true)
   418  	frame.SetBorders(1, 1, 0, 0, 1, 1)
   419  
   420  	drawFunc := func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) {
   421  		w, h := screen.Size()
   422  		frame.SetRect((w/2)-(width/2), (h/2)-(height/2), width, height)
   423  		return x, y, width, height
   424  	}
   425  
   426  	frame.SetDrawFunc(drawFunc)
   427  
   428  	return frame
   429  }